Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

use `useCallback` only work with `useReducer`?

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?

like image 389
Joji Avatar asked Feb 07 '26 10:02

Joji


1 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])
    },
    []
  )
like image 162
Gabriele Petrioli Avatar answered Feb 08 '26 22:02

Gabriele Petrioli