use `useCallback` only work with `useReducer`?

Viewed 1633

I have a very simple todo app built with React.

The App.js looks like this

const App = () => {
  const [todos, setTodos] = useState(initialState)

  const addTodo = (todo) => {
    todo.id = id()
    todo.done = false
    setTodos([...todos, todo])
  }

  const toggleDone = (id) => {
    setTodos(
      todos.map((todo) => {
        if (todo.id !== id) return todo

        return { ...todo, done: !todo.done }
      })
    )
  }

  return (
    <div className="App">
      <NewTodo onSubmit={addTodo} />
      <Todos todos={todos} onStatusChange={toggleDone} />
    </div>
  )
}

export default App

where <NewTodo> is the component that renders the input form to submit new todo item and <Todos /> is the component that renders the list of the todo items.

Now the problem is that when I toggle/change an existing todo item, the <NewTodo> will get re-rendered since the <App /> gets re-rendered and the prop it passes to <NewTodo>, which is addTodo will also change. Since it is a new <App /> every render the function defined in it will also be a new function.

To fix the problem, I first wrapped <NewTodo> in React.memo so it will skip re-renders when the props didn't change. And I wanted to use useCallback to get a memoized addTodo so that <NewTodo> will not get unnecessary re-renders.

  const addTodo = useCallback(
    (todo) => {
      todo.id = id()
      todo.done = false
      setTodos([…todos, todo])
    },
    [todos]
  )

But I realized that obviously addTodo is dependent upon todos which is the state that holds the existing todo items and it is changing when you toggle/change an existing todo item. So this memoized function will also change.

Then I switched my app from using useState to useReducer, I found that suddenly my addTodo is not dependent upon the state, at least that's what it looks like to me.



const reducer = (state = [], action) => {
  if (action.type === TODO_ADD) {
    return [...state, action.payload]
  }

  if (action.type === TODO_COMPLETE) {
    return state.map((todo) => {
      if (todo.id !== action.payload.id) return todo
      return { ...todo, done: !todo.done }
    })
  }

  return state
}

const App = () => {
  const [todos, dispatch] = useReducer(reducer, initialState)

  const addTodo = useCallback(
    (todo) => {
      dispatch({
        type: TODO_ADD,
        payload: {
          id: id(),
          done: false,
          ...todo,
        },
      })
    },
    [dispatch]
  )

  const toggleDone = (id) => {
    dispatch({
      type: TODO_COMPLETE,
      payload: {
        id,
      },
    })
  }

  return (
    <div className="App">
      <NewTodo onSubmit={addTodo} />
      <Todos todos={todos} onStatusChange={toggleDone} />
    </div>
  )
}

export default App

As you can see here addTodo is only announcing the action that happens to the state as opposed to doing something directly related to the state. So this would work

  const addTodo = useCallback(
    (todo) => {
      dispatch({
        type: TODO_ADD,
        payload: {
          id: id(),
          done: false,
          ...todo,
        },
      })
    },
    [dispatch]
  )

My question is, does this mean that useCallback never plays nicely with functions that contain useState? Is this ability to use useCallback to memoize the function considered a benefit of switching from useState to useReducer? If I don't want to switch to useReducer, is there a way to use useCallback with useState in this case?

3 Answers

Yes there is.

You need to use the update function syntax of the setTodos

const addTodo = useCallback(
    (todo) => {
      todo.id = id()
      todo.done = false
      setTodos((todos) => […todos, todo])
    },
    []
  )

You've dived down a bit of a rabbit hole! Your original problem was that your addTodo() function depended on the state todos, therefore whenever todos changed you needed to create a new addTodo function and pass that to NewTodo, causing a re-render.

You discovered useReducer, which could help with this since the reducer is passed the current state, and so does not need to capture it in the closure, so it can be stable over changes of todos. However, the React authors have already thought of this situation, and you don't need useReducer (which is really provided as a concession to those who like the Redux style of state updating!). As Gabriele Petrioli pointed out, you can just use the update usage of the state setter. See the docs.

This allows you to write the callback function that Gabriele has provided.

So to answer your final questions:

does this mean that useCallback always does not play nicely with function that contains useState?

useCallback can play perfectly nicely, but you need to be aware of what you are capturing in the closure you pass to useCallback, and if you are using a variable from useState in your callback you need to pass that variable in the list of deps to ensure that your closure is refreshed and it won't be called with out-of-date state.

And then you have to realize that the callback will be a new function thus causing re-renders to components that take it as an argument.

Is this ability to use useCallback to memoize the function considered a benefit of switching from useState to useReducer?

No, not really. useCallback does not prefer useState or useReducer. As I said, useReducer is really there to support a different style of programming, not because it provides functionality that is not available through other means.

If I don't want to switch to useReducer, is there a way to use useCallback with useState in this case?

Yes, as outlined above.

As mentioned by Gabriele Petrioli, You can use the callback syntax or you can keep the value of dependencies of your callback in a ref and use that ref in your callback instead of the state as mentioned here.

In your example, this approach would look something like this:

const [todos, setTodos] = useState([]);
const todosRef = useRef(todos);
useEffect(() => {
  todosRef.current = todos;
},[todos]);

const addTodo = useCallback(
  todo => {
    todo.id = id()
    todo.done = false
    setTodos([…todosRef.current, todo])
  },
  [todosRef]
)
Related