Must initializer functions for useState/useReducer be pure? [duplicate]
Asked Answered
R

5

4

Hier is an example of a problem I encountered:

import ReactDOM from "react-dom/client";
import React, { useState, useEffect } from "react";

const App = () => {
  // problematic
  const [radio, setRadio] = useState(1);
  useEffect(() => {
    const localRadio = localStorage.getItem('radio');
    if (localRadio) {
      setRadio(+localRadio);
    }
  }, []);

  // My "solution" using an initializer to read from localStorage
  // const [radio, setRadio] = useState(() => {
  //   const localRadio = localStorage.getItem('radio');
  //   return localRadio ? +localRadio : 1;
  // });

  useEffect(() => {
    localStorage.setItem('radio', radio);
  }, [radio]);
  const radioChangeHandler = (event) => {
    setRadio(+event.target.value);
  };
  return (
    <div>
      <h1>useState initializer demo</h1>
      <div className="radio-group" onChange={radioChangeHandler}>
        <input
          type="radio"
          value="1"
          checked={radio === 1}
          id="language1"
          name="languageChoice"
        />
        <label htmlFor="language1">Javascript</label>
        <input
          type="radio"
          value="2"
          checked={radio === 2}
          id="language2"
          name="languageChoice"
        />
        <label htmlFor="language2">HTML</label>
        <input
          type="radio"
          value="3"
          checked={radio === 3}
          id="language3"
          name="languageChoice"
        />
        <label htmlFor="language3">CSS</label>
      </div>
    </div>
  );
};

const container = document.querySelector("#root");
const root = ReactDOM.createRoot(container);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

The idea is to write into localStorage everytime radio changes. And when I open/refresh the site, it should read from the localStorage and set the value. But then I got the problem that the useEffect with [radio] always overwrites the other one, so I always get 1 as value for radio regardless of what's written in localStorage at the beginning.

(I just found out that the code actually works if I remove StrictMode, I thought I've tested it before... but react wants that the app also works if it renders twice anyway.)

My solution was to use an initializer function to read from localStorage (the commented out code). It works fine, and I was proud of me. Then I read on https://beta.reactjs.org/apis/react/useState#parameters

If you pass a function as initialState, it will be treated as an initializer function. It should be pure, should take no arguments, and should return a value of any type.

Also on https://beta.reactjs.org/apis/react/useReducer#my-reducer-or-initializer-function-runs-twice

Only component, initializer, and reducer functions need to be pure.

Does it mean that I shouldn't read from localStorage (or fetch data from API) in initializer? And what's the right way to do it?

Ramakrishna answered 3/10, 2022 at 19:39 Comment(13)
I already posted an answer that doesn't need an initializer function. But as for the initial solution (the commented code) with the initializer function, why are you returning +localRadio ? Why not simply returning the radio value stored in the local storage ? Isn't this what you want ? And it will be considered pureBrennen
Actually I updated the answer to address the initializerBrennen
@Brennen "why are you returning +localRadio?" - because radio should be a number but local storage stores stringsAlkylation
FWIW I wouldn't really consider reading from localStorage to be a side-effect in a state initializer function, you are initializing the sate from a data source. Writing to localStorage on the other hand, would be a side-effect IMO. This question/issue isn't really about function purity so much as it is about a misunderstanding of the React component lifecycle when state updates are processed. Reading/Writing to localStorage is completely synchronous while React state updates are processed asynchronously.Calculator
@DrewReese I don't think the duplicate answers this question, it doesn't mention purity at allAlkylation
@Alkylation I think the underlying issue of using two useEffect hooks to read from localStorage and initialize state, and to persist state changes is what led the OP to think function purity was the issue so they were asking about that. I initially read this as a bit of an XY problem. Perhaps I've misunderstood what the OP's goal was here? I guess the answer is simply, "yes, they must be pure because the React docs state they must be pure." Are you suggesting we un-duplicate since it might only be closely-related?Calculator
@DrewReese It's probably a bit of both. Yeah, I would've kept it open, but don't care strongly.Alkylation
@DrewReese I wouldn't really consider reading from localStorage to be a side-effect do we have any source for this? Looked it up cannot find anything concreteBremble
@TusharShahi I don't think you will find a concrete source TBH, it's sort of a grey area in what you might consider to be a "side-effect". The reason I don't consider it to be a side-effect is because it's a "reading" action, not a "writing" action, so from an external (from the function) viewpoint having executed the function nothing is changed/mutated/effected outside. Now, if in the course of loading state from localStorage you dispatch an action, IMO the dispatched action is a side-effect since there is an external effect.Calculator
@TusharShahi There is this answer from a reputable SO member if pure functions can read from "global state". By this definition I'd agree that if the React state initializer function is expected to be a "pure" function, that it depends only on its input and doesn't read from an external resource. This makes sense but then the second clause says it [initializer] should take no arguments, i.e. no inputs. From here it seems to be the case that either (a) the docs are accurate and the state should be loaded in the useEffect, or (b) the docs use a less strict definition.Calculator
Somewhat agree with the reading part. Since it does not modify anything outside, maybe it is not a side effect. I guess it is an opinion more than a fact thenBremble
@TusharShahi I can see the argument either way initializing the state from localStorage in an initializer function or in a useEffect hook, but to your comment about React and reusable state and the React.StrictMode and double-mounting I think the end result be the same using either method since the goal is to initialize the state to the "latest"/"current" value from localStorage, and this is why (IMO) it doesn't feel like an impure operation.Calculator
it's not a side effect, but it's not a pure function since it accesses external state. Being a pure function would mean that react could reliably cache the return value.Flowers
B
3

No. The documentation mentions that it should be pure.

If you pass a function as initialState, it will be treated as an initializer function. It should be pure, should take no arguments, and should return a value of any type.

The main idea is that with concurrent React coming into the picture, the rendering process is broken into pieces and done in parts, pausing and resuming at times. So React methods might be called more than once, and this can lead to invalid app state. (Eg: In first render localStorage has value 5, but in next it has mangoes :D). To detect these kind of issues React runs a lot of code twice: function passed to useState being one of them.

So one way to do this if you want to follow the above rule is, to read from localStorage in componentDidMount or useEffect(() => { ... },[]);. You can find relevant code in other answers.

You can have a ref variable which you update in this useEffect callback which indicates if this is the first render or not. You can have some null value for your state variable to let you know that you do not have the correct value for radio input. There are multiple options.

Bremble answered 3/10, 2022 at 20:9 Comment(2)
But effects run multiple times in development as well, so I don't see how this makes a difference…Alkylation
I think the problem/question is whether react might cache the output of the initializer function for the purposes of enabling some feature or performance boostFlowers
B
2

Try this approach:

const radioState = useState(-1);
const [radio, setRadio] = [radioState[0], (radioValue)=>{
    // Set local storage item
    radioState[1](radioValue);
}]

First you initialize the state and assign it to radioState. So doing radioState[0] will return the current state and radioState[1] (val) will set the state to val (Similar to [radio, setRadio]

Then write a custom array and de-structure it into [radio, setRadio] with setRadio being a custom function that takes a radioValue parameter and set the local storage and update the real state in the react component.

The idea is to write into localStorage everytime radio changes

This requirement will be satsified.

Applying this in your case

const radioState = useState(-1);
const [radio, setRadio] = [radioState[0], (radioValue)=>{
    localStorage.setItem('radio', radioValue);
    radioState[1](radioValue);
}]

Maybe you can also write a custom hook that sets the local storage every time its set function is called. This will be the most "elegant react" solution probably

And for the initializer, why are you returning +localRadio ? Don't you want to return the value saved in local storage ? If you do so it will be pure as far as react wants it (It will have no side effects: changing a global variable), because when react runs it twice it will return the same value

Brennen answered 3/10, 2022 at 20:14 Comment(1)
I was using + as an unary operator to make the string a number. Maybe you confused it with ++? And I thought that pure also means that the function should not depend on (or manipulate) "things out there" like local storage, api and so on.Ramakrishna
D
1

You can just check if the state value in your useEffect hook to see if it matches the initial value and it doesn't then you update your localStorage.

const [radio, setRadio] = useState(-1);

// Update the value of radio in the localStorage. If the value is -1 that means we are still in the initial render.
useEffect(() => {

if(radio === -1) return;

localStorage.setItem('radio', radio)

}, [radio]) 

// Update the value of radio on initial render. If localStorage doesn't 
// have radio stored, it will just set the radio state to 1, otherwise 
// it will load the existing value into the state.
useEffect(() => {

const _radio = localStorage.getItem('radio');

if(_radio === null) {
  setRadio(1);
} else {
  setRadio(_radio);
}

}, []) 

Donn answered 3/10, 2022 at 19:59 Comment(3)
But I want the initial value to be 1, not -1. And I want to be able to set the value to 1 later if I select the first radio button again.Ramakrishna
Well I could also change my other useState so that it sets radio to 1 if there's nothing saved in localStorage. ``` js useEffect(() => { const localRadio = localStorage.getItem('radio'); if (localRadio) { setRadio(+localRadio); } else { setRadio(1); } }, []); ``` I just tested it, it works... But I think there should be a more elegant solution. Also I'd still like to know the answer about the initializer.Ramakrishna
@TunHuang Check my edit, I think it's more of what you're looking forDonn
A
1

I don't think there's a need for the initialiser function to be pure. Sure, it shouldn't have side effects that change anything, but it's fine to have its result depend on the environment - be that component props or global state such as localStorage. I wouldn't use any effect hooks at all in your case:

const [radio, setRadio] = useState(() => {
  const storedRadio = localStorage.getItem('radio');
  return storedRadio != null ? +localRadio : 1;
});

const radioChangeHandler = (event) => {
  const newRadio = +event.target.value;
  localStorage.setItem('radio', newRadio);
  setRadio(newRadio);
};
Alkylation answered 3/10, 2022 at 20:43 Comment(1)
Btw, even official documentation on react.dev has an example of impurities regarding reading LocalStorage right in component rendering function without useEffect react.dev/reference/react/…Gurgitation
K
1

There's no requirement that the initializer must be pure. You could even write your own useLocalStorage hook so that it's easy to persist any state -

function useLocalStorage(key, initState) {
  const [state, setState] = React.useState(_ => {
    const value = localStorage.getItem(key)
    if (value == null)
      return typeof initState == "function"
        ? initState()
        : initState
    else
      return JSON.parse(value)
  })
  const persistState = React.useCallback(update => {
    setState(prevState => {
      const nextState =
        typeof update == "function"
          ? update(prevState)
          : update
      localStorage.setItem(key, JSON.stringify(nextState))
      return nextState
    })
  }, [key])
  return [state, persistState]
}

The useLocalStorage hook can be used like this -

import { useLocalStorage } from "./hooks.js"

function ContactUs() {
  const [name, setName] = useLocalStorage("forms:contact:name", "")
  const [email, setEmail] = useLocalStorage("forms:contact:email", "")
  const [message, setMessage] = useLocalStorage("forms:contact:message", "")
  // ...
  return <form>
    <input value={name} onChange={e => setName(e.target.value)} />
    <input value={email} onChange={e => setEmail(e.target.value)} />
    <textarea value={message} onChange={e => setMessage(e.target.value)} />
    {/* ... */}
  </form>
}

useLocalStorage has the same API as useState. It supports functional updates -

function ContactUs({ messageLimit = 200 }) {
  // ...
  const [message, setMessage] = useLocalStorage("forms:contact:message", "")
  const updateMessage = event => {
    // functional update
    setMessage(prevText => {
      if (event.target.value.length > messageLimit)
        return prevText
      else
        return event.target.value
    })
  }
  return (
    // ...
  )
}

As well as lazy initial state -

function MyComponent(props) {
  const [state, setState] = useLocalStorage("mykey", _ => {
    // lazy initial state; first render only
    return someExpensiveComputation(props)
  })
  return (
    // ...
  )
}

useLocalStorage accepts state of any shape -

function CharacterSheet({ id }) {
  const [character, setCharacter] = useLocalStorage(`character:${id}`, {
    name: "Unnamed One",
    class: "None", 
    equipment: [
      { type: "wearable", name: "humble garment" }
    ],
  })
  // ...
  return (
    <div>
      <h1>{character.name}</h1>
      <h2>{character.class}</h2>
      {character.equipment.map(e => ...)}
      {/* ... */}
    </div>
  )
}
Kissinger answered 3/10, 2022 at 21:14 Comment(1)
Good approach, though you should use typeof x == 'function' instead of x?.constructor === FunctionAlkylation

© 2022 - 2024 — McMap. All rights reserved.