Home > database >  Problem with useCallback not working as expected
Problem with useCallback not working as expected

Time:10-13

I'm expecting that when I pass a function wrapped in useCallback as an attribute to another function, that the function will still work, but the function will not be re-created on each call.

I have this problem in a large application, but I've made the problem into a small, reproducible example that I can share here.

Below is the code that I'm struggling with. I have commented out my attempt at working with useCallback that is not working as expected. When I don't use useCallback (the non-commented out code), the app toggles the theme as I expect.

I have the simple example in codesandbox at this URL:

https://codesandbox.io/s/github/pkellner/callback-theme-toggle

If I un-comment the useCallback line, the theme toggles once, then never toggles again.

My expectation is that with the useCallback code that the theme will toggle and appMenu.js will not get re-rendered on every theme toggle click.

Here is the /pages/index.js

import {useCallback, useContext} from "react";
import AppMenu from "../src/AppMenu";
import { ThemeContext, ThemeProvider } from "../src/ThemeContext";

function Inner() {
  const { toggleTheme, darkTheme } = useContext(ThemeContext);

  return (
    <div>
      <h1>HOME</h1>
      {/*<AppMenu toggleTheme={useCallback(toggleTheme,[])} />*/}
      <AppMenu toggleTheme={toggleTheme} />
      <h2>darkTheme: {darkTheme === true ? "true" : "false"}</h2>
    </div>
  );
}

export default function Home() {
  return (
    <ThemeProvider>
      <Inner />
    </ThemeProvider>
  );
}

CodePudding user response:

When you do

useCallback(toggleTheme,[])

This tells React that the value to use at that point is the value of toggleTheme when the component mounts. Since the dependency array is empty, the callback never changes.

When the component mounts, toggleTheme in context closes over the state value at that point:

const toggleTheme = () => {
    console.log(`useTheme:toggleTheme:${darkTheme}`);
    setDarkTheme(!darkTheme); // <---- reference to state
};

darkTheme is false at that point. Although the value passed down by useContext changes when the component re-renders, because you're using useCallback with an empty dependency array, the value returned by useCallback remains the same - it refers to the initial toggleTheme from context, where darkTheme is its initial value (so further clicks don't appear to produce a change, because you're setting state to the same state as it is currently).

useCallback doesn't make any sense here; just use the value returned by useContext so that it always has the most up-to-date state value, instead of adding extra complication. The function is re-created by context, not by your use (or lack thereof) of useCallback.

If you want AppMenu to not re-render, then you should memoize it - and you should fix the context's toggleTheme so that the function it passes down doesn't depend on a reference to a (possibly stale) state value in the closure.

const toggleTheme = () => {
    setDarkTheme(theme => !theme);
};
function Inner() {
  const { toggleTheme, darkTheme } = useContext(ThemeContext);
  const menu = useMemo(() => <AppMenu toggleTheme={toggleTheme} />);
  return (
    <div>
      <h1>HOME</h1>
      {menu}
      <h2>darkTheme: {darkTheme === true ? "true" : "false"}</h2>
    </div>
  );
}

CodePudding user response:

There's a few points to address here.

When you useCallback, you create a closure around whatever dependencies it has. The callback needs to be recreated whenever one of those dependencies changes. The "meat" of your callback is this:

const toggleTheme = () => {
  setDarkTheme(!darkTheme);
};

Which means you have two dependencies - setDarkTheme (which is functionally stable), and darkTheme (which changes). So, when you wrap this in a useCallback and don't declare darkTheme as a dependency, the callback closures over the initial value of darkTheme, and always then uses that to toggle on (which is why the toggle works one time, but then never works again).

But good news! You can get rid of your dependency on darkTheme by using the callback version of setState -

const toggleTheme = () => {
  setDarkTheme(prev => !prev);
};

Boom! Now you have a functionally stable callback! You can safely wrap this into a useCallback hook with no dependencies, and it will work as you had intended!

But... you'll notice that even if you do that, AppMenu will always re-render when the them changes. Toggling the theme updates your Context object, and:

All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes.

(from the doco of Context).

Inner is a consumer of the context (via the useContext hook), and the parent of AppMenu - which means they both re-render when the context changes.

  • Related