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.
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.