DEV Community

Cover image for An in-depth guide to useState hook
Dino Kacavenda
Dino Kacavenda

Posted on • Updated on • Originally published at kaca.hashnode.dev

An in-depth guide to useState hook

In this article, I will draw attention to some problems and edge cases that may occur while using the useState hook. This hook stores a value that is used when rendering components. It is one of the most commonly used hooks, and most of the time you can use it with no problems and it will behave as expected. But there are some exceptions, which I will cover in this article.

The topics that I will address are:

When will setState cause a re-render?

If you are familiar with class components, you might think that the hook equivalent of this.setState always causes a re-render. The hook method uses the Object.is on every state change (call of setState method) and compares the previous value with the newer one. That being said, if we use the useState with primitive values (number, string, boolean, undefined, symbol) it will not cause a re-render if the value didn't change:

Object.is(2, 2); // true
Object.is("value", "value"); // true
Object.is(true, true); // true
Object.is(undefined, undefined); // true
Object.is(null, null); // true
Enter fullscreen mode Exit fullscreen mode

If we use the useState with objects or functions, a re-render would happen only when the reference changes:

Object.is({}, {}); // false
Object.is([], []); // false
Object.is(() => console.log(""), () => console.log("")); // false
const foo = {a: 1};
const clone = foo;
Object.is(foo, clone); // true
Object.is(foo, {a: 1}); // false
Enter fullscreen mode Exit fullscreen mode

This is one of the reasons why we should never directly mutate state because React will not detect the change and cause a re-render. It is also important when dealing with objects/arrays to not only set the new values but also to copy the previous ones (if you used React class components this behavior is different since React would have merged new and previous state values, so you would only need to set changes). So, if we have a complex state with nested objects:

// complex state with nested objects
const [complexState, setComplexState] = useState({
    foo: 'bar',
    bar: 'foo',
    errors: {
         foo: 'required',
         bar: 'required'
    }
})
Enter fullscreen mode Exit fullscreen mode

and want to change the errors.foo value we would do it like this:

setComplexState({
    ...complexState,
    errors: {
         ...complexState.errors,   // we need to copy deeply nested object
        foo: 'new value'
    }
})
Enter fullscreen mode Exit fullscreen mode

Object.is used to compare states when setState is called. Primitive values are compared by value, and the complex ones by reference

React.memo and changing state

React.memo will not prevent a re-render of the component where we use the useState hook. React.memo is strictly used to bailout of re-rendering child components when their parent re-renders. I intentionally didn't use the phrase: "when props change", since by default child components will re-render even if props stayed the same, and their parent rendered (only memoized components do a shallow comparison of props).

The mentioned behavior differentiates from its class component equivalent: shouldComponentUpdate, which is triggered when both state or props change, and can bail out of rendering even when the state changes.

React.memo doesn't prevent re-rendering when state changes when using useState

setState changes are not immediately visible

When we call setState, state change won’t be visible straight away. React will queue the update and sometimes even batch multiple updates so our components don't render too many times (more on that in the next section).

const [state, setState] = useState(0);

useEffect(() => {
    setState(1);
    console.log(state); // state is still 0
}, []); 

Enter fullscreen mode Exit fullscreen mode

State changes are visible in the next render

Batching

It is quite common that we use multiple useState hooks, and call their set methods inside the same callback/useEffect call. React will by default batch those updates togheter so that our component will render only once, and not for each setState call:

export default function Component() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);

  useEffect(() => {
    console.log({ state1, state2 });
  });

  const onClick = () => {
    setState1(state1 + 1);
    setState2(state2 + 1);
  };

  return <button onClick={onClick}>Click Me</button>;
}
Enter fullscreen mode Exit fullscreen mode

when we click on the button, in the next render, we will see updated state1 and state2. There will never be a situation in which state1 !== state2.

However, there are some cases in which React will not batch updates:

  • if we call setState methods inside an async function
  • if we call setState inside a setTimeout/setInterval

This is usually not a big performance issue, since React renders are pretty fast, but we could end up in an intermediate state that we didn't expect, and it could cause our application to stop working.

If we alter the previous example, into changing the state after a timeout:

export default function Component() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);

  useEffect(() => {
    console.log({ state1, state2 });
  });

  const onClick = () => {
    // state is changed inside a setTimeout now
    setTimeout(() => {
      setState1(state1 + 1);
      setState2(state2 + 1);
    }, 0)
  };

  return <button onClick={onClick}>Click Me</button>;
}
Enter fullscreen mode Exit fullscreen mode

By clicking on the set button, our component would render twice: the first render would update state1, and the second one would update state2.

There is an unstable API provided by React which can batch updates even inside async/setTimeout calls: React.unstable_batchedupdates. It is used internally by React when batching changes in event handlers or during a sync flow.

I personally prefer to use the useReducer hook when dealing with interconnected states. It allows me to write exact state changes (creating a state machine of sorts) with ease and helps me eliminate the possibility of rendering our component in an intermediate state. An example of this is a simple useFetch hook, that clearly defines all possible states:

function useFetch(initialState = {isLoading: true}) {
  // defined our state machine, so we are certain only these states
  // are possible and all connected states are updated in single render
  const reducer = (state, action) => {
    switch (action.type) {
        case 'request':
            return { isLoading: true };
        case 'response': {
            return { isLoading: false, data: action.data };
        }
        case 'error': {
            return { isLoading: false, error: action.error };
        }
        default:
            return state;
    }
  };

  const [fetchDataState, dispatch] = useReducer(reducer, initialState);

  const fetchData = async (fetchOptions, abortSignal) => {
    try {
        dispatch({ type: 'request' });
        const data = await fetcher.fetchData(fetchOptions, abortSignal);
        // this will set both loading and fetched data for next render
        dispatch({ type: 'response', data: data });
    } catch (e) {
        dispatch({ type: 'error', error: e });
    }
  };

  return { ...fetchDataState, fetchData };
}
Enter fullscreen mode Exit fullscreen mode

The state changes will not be batched when using inside async or setTimeout/setInterval flow

Lazy initialization

When we want to initialize state with some potentially expensive operation, which we don't want triggering on every render (for example filtering of a big list), we can put a custom function when initializing useState. That function will only be called on the first render, and its results will be set as the initial value of the useState:

const [state, setState] = useState(() => {
     props.initialValue.filter(...) // expensive operation
})
Enter fullscreen mode Exit fullscreen mode

You just need to be careful that this is only called on the first render. If I have props, for example, that are used to initialize state, I like to prefix the prop name with initial or default to signal other devs that this value will not be synced if it changes.

useState supports lazy initialization for expensive operations

When to use setState with a callback?

setState has two call signatures:

  • you can call it with a new value
  • you can call it with a callback that receives the current value as an argument and returns the new value

The callback signature is beneficial when calling setState inside a useCallback hook so that we don't break memoization.

If we have a simple component that uses useState and useCallback hooks with a memoized child component, and write it using the simple setState call signature:

const [state, setState] = useState(0);

const onValueChanged = useCallback(() => {
     setState(state + 1);
}, [state, setState]);

return <div>
     {state}
     <MemoizedChild onValueChanged={onValueChanged }  />
</div>

Enter fullscreen mode Exit fullscreen mode

we will ruin the optimization of our MemoizedChild. Since onValueChanged will change on every state change, its reference will change as well, which will result in different props being sent to our child component (even if it doesn't use state in its props). This can be fixed easily by using the callback signature:

const [state, setState] = useState(0);

const onValueChanged = useCallback(() => {
     setState(prevState => prevState + 1); // change to callback signature
}, [setState]); // remove state from dependencies since callback will provide current value

return <div>
     {state}
     <MemoizedChild onValueChanged={onValueChanged }  />
</div>

Enter fullscreen mode Exit fullscreen mode

This will work because the setState reference will be constant throughout the whole lifecycle of our component. With this adjustment, the MemoizedChild component will not render when the state changes.

Use the callback signature of setState when using with useCallback

Using useState to store element reference

When you need to reference a React element you can usually use the useRef hook. However, what if you want to do something with the element when it is first rendered (i.e., attach an event listener, calculate dimensions, ...) or if you want to use the reference as a dependency for useEffect/useCallback? In these cases useRef will not trigger a re-render of our component, so we would need to combine it with the useEffect. You could use useState to get the object reference, and it would force a re-render after the element is rendered, so you could access it:

export default function Component() {
  const [buttonRef, setButtonRef] = useState();

  useEffect(() => {
    console.log({ buttonRef });
  });

  return <button ref={setButtonRef}>Click Me</button>;
}
Enter fullscreen mode Exit fullscreen mode

This way you would save the element reference in the state as soon as the element is rendered, and could safely use it without manually syncing it.

Conclusion

In this article, I covered some advanced useState cases. Hope you enjoyed it and found it useful :)

If you are interested in learning more about this topic, you can check these links:

Latest comments (2)

Collapse
 
dylanesque profile image
Michael Caveney

Pssst, async isn't why setState doesn't update immediately: stackoverflow.com/questions/540692...

Collapse
 
dino_kaca profile image
Dino Kacavenda

Async was definitely a poor choice of word (since it is easily mistaken in this context). I have edited the name of the section, and also included this link of yours since it is very useful. Thank you for your feedback :)