loading...

Update boolean state right with React Hooks

alexkhismatulin profile image Alex Khismatulin ・5 min read

Recently I found a construction like this while doing code review:

const MyComponent = (props) => {
  const [isToggled, setIsToggled] = React.useState(false);
  const toggle = React.useCallback(() => setIsToggled(!isToggled));

  return ...;
};

Creating a boolean state and a toggle method for it is a pretty common use case. The spinnet is 100% correct in terms of functionality. But it could be better in terms of performance. Let's see how it can be improved.

So what's wrong?

First things first – useCallback does nothing in this implementation. Unless a dependencies array is passed as a second parameter, useCallback is not keeping the same reference to a callback through renders and is equal to the following callback declaration:

const toggle = () => setIsToggled(!isToggled);

Also, current implementation violates the exhaustive dependencies rule: every value referenced inside the function should also appear in the dependencies array. This is required to ensure that values inside a callback are always up-to-date and avoid any bugs related to that.

Let's see in practice how these two impact performance. First, let's create a simple RendersCounter component that takes a single onClick prop. It's going to count how many times a component was rendered:

import React from 'react';

const RendersCounter = ({ onClick }) => {
  const rendersCountRef = React.useRef(0);
  rendersCountRef.current += 1;

  return (
    <div>
      <span>
        RendersCounter rendered <b>{rendersCountRef.current}</b> time(s)
      </span>
      <button style={{ marginLeft: '10px' }} onClick={onClick}>
        toggle
      </button>
    </div>
  )
};

export default React.memo(RendersCounter);

Note that RendersCounter is wrapped with React.memo. The optimizations we're going to make are only working if a child component is a pure component: it's an instance of React.PureComponent, a functional component wrapped with React.memo, or has referential equality render optimization via shouldComponentUpdate or any other way to do it. If you don't have any of those implemented for a child component, it will be re-rendered every time a parent component is re-rendered regardless of the way you implement a callback.

Now let's use this component to see what happens if we don't pass dependencies to useCallback at all. I will create two separate state handlers: one for our boolean state and another one for storing a random number.

const BasicBooleanState = () => {
  const [isToggled, setIsToggled] = React.useState(false);
  const toggle = React.useCallback(() => setIsToggled(!isToggled));

  const [randomNumber, setRandomNumber] = React.useState(Math.random());
  const generateRandomNumber = React.useCallback(
    () => setRandomNumber(Math.random()),
    [],
  );

  return (
    <div>
      <div>
        Current random number is <b>{randomNumber}</b>
        <button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
          regenerate
        </button>
      </div>
      <div>
        Boolean is set to <b>{String(isToggled)}</b>.
      </div>
      <RendersCounter onClick={toggle} />
    </div>
  );
}

RendersCounter re-renders even though the boolean state is not changed at all!
Alt Text

As said before, the current toggle implementation with useCallback is equal to a regular arrow function declaration. It's re-created each render so RendersCounter gets a referentially different onClick prop that causes its re-render when it doesn't have to.

Try it yourself

Fixing missing dependencies

React documentation says:

Every value referenced inside the function should also appear in the dependencies array

If you don't follow this rule you might end up having outdated values inside a callback. There are two external values used inside the toggle callback: isToggled and setIsToggled. Let's put them into the useCallback's dependencies array.

const BasicBooleanState = () => {
  const [isToggled, setIsToggled] = React.useState(false);

  // here we added [isToggled, setIsToggled] as a second parameter
  const toggle = React.useCallback(
    () => setIsToggled(!isToggled),
    [isToggled, setIsToggled],
  );

  const [randomNumber, setRandomNumber] = React.useState(Math.random());
  const generateRandomNumber = React.useCallback(
    () => setRandomNumber(Math.random()),
    [],
  );

  return (
    <div>
      <div>
        Current random number is <b>{randomNumber}</b>
        <button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
          regenerate
        </button>
      </div>
      <div>
        Boolean is set to <b>{String(isToggled)}</b>.
      </div>
      <RendersCounter onClick={toggle} />
    </div>
  );
}

Now RendersCounter is not re-rendering when a random number changes! We said our callback to update only when isToggled or setIsToggled change so it's referentially equal unless isToggled changes.
Alt Text

But when we toggle the boolean state from the RendersCounter it gets re-rendered. And this makes sense because isToggled changes and it's a part the useCallback's dependencies array.
Alt Text

Try it yourself

Optimizing a callback

To fix the problem of re-creating the toggle callback we need a way to avoid depending on isToggled directly but still have its actual value inside a callback. Here's what useRef can help with. We just need to create a reference once and update its value when isToggled changes. Then we replace isToggled with the reference in the dependencies array and callback itself and that's it!

Let's create a custom hook that would return a current boolean state and a toggle method that is changing a boolean value and never gets re-created

// it might be a project-level reusable hook
const useToggle = (initialState) => {
  const [isToggled, setIsToggled] = React.useState(initialState);
  const isToggledRef = React.useRef(isToggled);

  // put [isToggledRef, setIsToggled] into the useCallback's dependencies array
  // these values never change so the calllback is not going to be ever re-created
  const toggle = React.useCallback(
    () => setIsToggled(!isToggledRef.current),
    [isToggledRef, setIsToggled],
  );

  // keep the value in isToggledRef actual
  // when isToggled changes, isToggledRef is updated accordingly
  React.useEffect(
    () => {
      isToggledRef.current = isToggled;
    },
    [isToggled],
  );

  return [isToggled, toggle];
}

Instead of isToggled we use isToggledRef to create the toggle callback. Both isToggledRef and setIsToggled are created only once and React ensures they never change and are referentially equal through renders. That means there's no reason for the toggle callback to be ever re-created.

To make sure the value in isToggledRef is up-to-date we use useEffect with a single isToggled dependency in the dependencies array. It will be executed only when isToggled changes.

It's time to use the hook we created:

const OptimizedBooleanState = () => {
  const [isToggled, toggle] = useToggle(false);

  const [randomNumber, setRandomNumber] = React.useState(Math.random());
  const generateRandomNumber = React.useCallback(
    () => setRandomNumber(Math.random()),
    [],
  );

  return (
    <div>
      <div>
        Current random number is <b>{randomNumber}</b>
        <button style={{ marginLeft: '10px' }} onClick={generateRandomNumber}>
          regenerate
        </button>
      </div>
      <div>
        Boolean is set to <b>{String(isToggled)}</b>.
      </div>
      <RendersCounter onClick={toggle} />
    </div>
  );
}

Now RenderCounter never gets re-rendered!
Alt Text

Try it yourself

Update

As Juan Gabriel S. Palarpalar mentioned in comments, there's no need to use refs in this case. The desirable behavior can be achieved with a functional state updater. Instead of passing a value to setIsToggled we need to pass a function that takes the current state as the first argument. This really makes the hook way clearer:

setIsToggled(state => !state);

Here's how updated useToggle hook looks:

const useToggle = (initialState) => {
  const [isToggled, setIsToggled] = React.useState(initialState);

  // put [setIsToggled] into the useCallback's dependencies array
  // this value never changes so the callback is not going to be ever re-created
  const toggle = React.useCallback(
    () => setIsToggled(state => !state),
    [setIsToggled],
  );

  return [isToggled, toggle];
}

Try it yourself

Conclusion

At the end of the day, useCallback is only about optimization. Your code will still work properly if you declare a callback as a plain arrow function so it's up to you to find a balance between optimization and code brevity.

React Hooks API is super powerful. It allows you to write clear declarative code. It can also boost your app's performance if cooked right.

Thank you for reading!

Discussion

markdown guide
 

a note: eslint react hook rules don't complain about a state setter created by setState as a missing dependency as it never changes. So while it doesn't hurt, an empty dependencies array would work just fine in this case.

 

Sounds good. I would put anything I use into dependencies for consistency but it’s a good note for those who feels better when the dependencies array is empty. Thank you!

 

You can provide a callback for useState that accepts the current state.

setIsToggled(state => !state)

I don't get why you have to use refs for this.

 

You're 100% right, there's no need to mess with refs in this case. I totally forgot about the functional state update. I'm going to add a note about that. Thank you!