React Optimizations: Stop Memoizing Blindly
2026-06-30
13 min read
React Optimizations: Stop Memoizing Blindly
Let's talk about memoization, caching, and optimizations in React. If you're working on a project using a React version earlier than 19, you've probably run into (and worked with) hooks like useMemo, useCallback, and their component variant memo, where using these hooks is a decision the developer has to make.
In React 19, though, the compiler takes care of this for you. Either way, it's really important to understand how these tools work, since a solid grasp of them will help the compiler do its job better, optimize correctly, and make your app genuinely fast.
Why memoize?
There's a good chance that, at some point, you've run into performance issues where your app feels slow. This can happen because of an expensive calculation running on every render, or a component re-rendering way more than it needs to. In these cases, memoization is a really useful tool for improving your app's performance.
So what are useMemo and useCallback? They're hooks that let us memoize values and functions, respectively. That means that if the value or function doesn't change, React won't recalculate it, which helps improve our app's performance.
memo, on the other hand, is a HOC (Higher Order Component) that lets us memoize an entire component, preventing it from re-rendering if its props haven't changed (same idea, but for components instead of values or functions).
function useExpensiveCalculation(input) {
const result = useMemo(() => {
return input * 2; // Simple example, let's assume this calculation is expensive
# }, [input]); // Only recalculates if 'input' changes
return result;
}
The idea is that, when you use this hook, if the value of input doesn't change, React won't run the expensive calculation again — it'll just return the previously memoized value. Here's an example of what a component using this hook would look like:
const ExpensiveComponent = ({ input }) => {
const result = useExpensiveCalculation(input);
return <div>Result: {result}</div>;
};
For a more detailed example, here's one from React's official docs showing the difference between a component that re-renders constantly and one that doesn't, thanks to memoization.
So should I memoize everything, then?
Not necessarily. Memoization has a cost, and if the value or function you're memoizing changes frequently, the overhead can outweigh the benefits. That's why it's important to evaluate when and how to use these tools.
On top of that, if we don't understand the rules React follows when memoizing, the compiler won't be able to help us optimize anything. React provides examples of when it's worth memoizing and when it's not, both for useMemo and for useCallback.
You'll notice the examples throughout this article are pretty simple — keep in mind they're for illustration purposes. In real code, these examples probably wouldn't even be good candidates for memoization.
Understanding memoization in React
Both useMemo and useCallback take a list of dependencies — these are the variables used when calculating the value or function we're memoizing (and they're the ones that go inside the array passed to these hooks). If any of these dependencies change, React will recalculate the value or function and update the memoized result.
For the memo HOC, memoization depends on the props the component receives, instead of a dependency list.
function FormComponent({ processId }) {
const [formInfo, setFormInfo] = useState("");
const handleSubmit = useCallback((event) => {
event.preventDefault();
const trimmedProcessId = processId.trim().replace(/\s+/g, '');
const processedData = processFormInfo(formInfo, trimmedProcessId);
api.post({
url: '/api/submit',
data: processedData,
});
}, [formInfo, processId]); // handleSubmit only gets recreated if formInfo or processId change
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formInfo}
onChange={(e) => setFormInfo(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
}
As you can see in the example, if formInfo or processId change, the handleSubmit function gets recreated when the component re-renders. If they don't change, React reuses the cached function, avoiding unnecessary recalculations and optimizing the component's performance. We'll see later on why this particular example is actually a case where memoizing is NOT a good idea, but it works fine for illustration purposes.
So how does React know if a dependency changed?
Now we're getting into pure JavaScript territory. TL;DR: it depends on the type of variable you're passing. If it's a primitive (string, number, boolean), React compares the value directly. If it's an object or an array, React compares by reference.
Comparing primitive values
For the first case, we generally won't run into problems, since the comparison is direct ("hello" === "hello", 1 === 1, false === false, etc).
Going back to the previous example, if formInfo is a string and processId is a number, we can see this is actually a bad use of memoization — every time the user types something into the input, formInfo changes, and the handleSubmit function gets recreated. That makes the memoization completely useless.
Comparing objects and arrays
Working with objects or arrays can be a bit trickier, and this is where we need to be extra careful, since we're now dealing with references.
Let's look at a very common case in React: in JavaScript, a function is an object! That's why functions are also compared by reference.
Let's look at an example below and draw some conclusions:
function ParentComponent() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<ChildComponent onClick={handleClick} />
</div>
);
}
function ChildComponent({ onClick }) {
const memoizedExpensiveOnClick = useCallback(() => {
// Expensive logic we want to memoize before running onClick
onClick();
}, [onClick]);
return <button onClick={onClick}>Child</button>;
}
Here we can see the parent re-renders every time the counter changes, and the child has an expensive calculation we want to memoize, so we're tempted to reach for useCallback. Let's break down what actually happens:
- The parent re-renders every time the counter changes.
- Since the parent re-renders, the
handleClickfunction gets recreated (different reference). - The child receives a new reference for the
onClickfunction, and therefore re-renders. - When it re-renders, the dependencies of the expensive calculation get evaluated. Since we have different references (the
onClickfunction changed), the expensive calculation runs again anyway, so the optimization is completely useless.
The React 19 compiler follows this same logic. If the compiler detects that a function or value is being created on every render, it won't memoize it, since there's no point.
Jack Herrington gives a great explanation of this kind of comparison in his video on fixing memory issues in React.
When and how to memoize
Rules and recommendations
1. Golden rule: if the value or function you're memoizing changes frequently, it's not worth memoizing.
In this first example, theme is very unlikely to update often (or might never change at all), which makes the handleClick function a good candidate for memoization.
function ToggleThemeButton({ theme }) {
const handleClick = useCallback(() => {
setTheme(theme === "light" ? "dark" : "light");
}, [theme]);
return <button onClick={handleClick}>Switch theme</button>;
}
In this example, userName changes frequently (let's say it comes from an input that updates with every keystroke). So memoizing here isn't worth it.
function Component({ userName }) {
const handleClick = useCallback(() => {
sentToServer({
method: "PATCH",
endpoint: "api/users",
body: { name: userName }
})
}, [userName]); // we depend on userName, so it gets recreated every time it changes
return <button onClick={handleClick}>Submit</button>;
}
2. Recommendation: use pure functions.
Pure functions are ones that don't depend on external variables. This way, we can avoid the function changing reference and getting recreated on every render.
Going back to the previous example of sending data to the server, let's look at how we can apply pure functions and memoize correctly:
function Component({ userName }) {
const handleClick = useCallback((name) => {
sentToServer({
method: "PATCH",
endpoint: "api/users",
body: { name }
})
}, []); // We don't depend on userName, so we don't need to add it to the dependencies
return <button onClick={() => handleClick(userName)}>Submit</button>;
}
Here's something worth keeping in mind that can help a lot: the setter functions returned by useState (like setTheme or setCount) are stable and don't change between renders. We can take advantage of this to write pure functions without needing to add them to the dependency array.
On top of that, we can take advantage of the fact that these setter functions receive the current state value as a parameter, which lets us write even more flexible pure functions:
function Component() {
const [count, setCount] = useState(0);
// setCount is stable, it doesn't change between renders
// So we can write pure functions without a dependencies array
const handleIncrement = useCallback(() => {
setCount(count + 1);
}, []);
const handleDecrement = useCallback(() => {
setCount(count + 1);
}, []);
return (
<>
<p>Count: {count}</p>
<button onClick={handleIncrement}>Increment</button>
<button onClick={handleDecrement}>Decrement</button>
</>
);
}
If you want to dig deeper into pure functions and/or components, check out this React article for more details.
Recommendations on where to memoize
With experience, I've learned to spot use cases where memoizing is a good idea without having to weigh the tradeoffs every single time. Below are a few of these cases:
UI component libraries
If we're building a UI component library or a hooks library, we have to keep in mind that we don't know who's going to use our library, or what their performance requirements are.
It's not unusual to find a dependency tree like this one in a mid-sized project:
UI component library (generic)
└── UI component library (specific)
├── Templates library
├── Project A (final product)
└── Project B (final product)
In this scenario, both Project A and Project B consume two different layers:
- the Templates library, which provides components with reusable business logic
- the specific UI component library, which provides the visual and design layer applied to the product
It's very likely that the generic UI component library gets used across many different projects, each with its own performance requirements. So if we're working on it, it's a good idea to memoize its components, so that if a project ends up with high performance needs, it won't be hurt by consuming elements that aren't memoized.
Reusable hooks libraries
With a reusable hooks library, the scenario is pretty similar — we don't know how it'll be used or whether the final project actually has high performance requirements.
So for reusable hooks, should I force memoization by leaving the dependency array empty?
Careful! When memoizing reusable hooks, it's very tempting to drop variables and functions from the dependency array. This should be completely off-limits if you're working on a project where you don't control the code consuming these hooks, since it can create bugs that are extremely hard for your library's consumers to track down.
I'd go as far as to say this recommendation holds for any project, whether it's a final product or a reusable library (which is why both linters and the React compiler itself throw warnings about this).
Context Providers
Context Providers are another case where memoizing is almost always worth it. To understand why, we first need to be clear on how the context data flow works.
When a component consumes a context, React subscribes it to the corresponding Provider. From that point on, every time the Provider's value changes, all consumers re-render. The flow always goes top to bottom:
- the Provider changes
- the Consumers react
In turn, a Consumer never triggers a re-render of the Provider. The problem shows up when the Provider lives inside a component that re-renders frequently. Let's see what happens without memoization:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
Let's break down step by step what happens every time ThemeProvider re-renders (let's assume it's due to some external cause):
- The component runs and the
toggleThemefunction gets recreated (new reference). - The
{ theme, toggleTheme }object passed toThemeContext.Providergets recreated (new reference), even thoughthemehasn't changed. - React compares the previous
valuewith the new one. Since the object's reference changed, it considers the value different. - Every component consuming
ThemeContextre-renders, even thoughthemeis the same as before.
This causes a cascade of unnecessary re-renders across the entire subtree consuming the context. The fix is to memoize both the function and the value object:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Now the flow works as expected:
toggleThemekeeps its reference between renders thanks touseCallback.- The
valueobject only gets recreated ifthemeortoggleThemechange, thanks touseMemo. - React compares the previous
valuewith the new one and, if the reference hasn't changed, doesn't notify the Consumers. - Components consuming the context only re-render when
themeactually changes.
What if the context has lots of values that change frequently?
In that case, it can be worth splitting the context into two: one for values that change often and one for values that rarely change. That way, consumers that only need the stable values won't re-render because of changes to the dynamic ones.
// We split state from the dispatcher
const ThemeStateContext = createContext(null);
const ThemeDispatchContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}, []);
return (
<ThemeDispatchContext.Provider value={toggleTheme}>
<ThemeStateContext.Provider value={theme}>
{children}
</ThemeStateContext.Provider>
</ThemeDispatchContext.Provider>
);
}
This way, a component that only needs toggleTheme won't re-render when theme changes. This technique is widely used in projects with global state contexts.
What's not worth memoizing
We've covered a lot about when and how to memoize, but it's just as important to know when not to. Memoizing indiscriminately doesn't just fail to help — it can actually hurt performance and make your code harder to read and maintain. Here's a list of cases where memoizing isn't worth it:
- Simple or cheap calculations.
- Values or functions that change on every render.
- Components that always receive different props.
- Very simple components.
- Inline functions in JSX applied to native elements.
It's pretty common to see code like this:
// useCallback here is pointless
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []);
return <input onChange={handleChange} />;
In this case, input is a native DOM element. React doesn't apply memo to native elements, so it's always going to "render" (reconcile) regardless of whether onChange changed or not. Memoization here doesn't provide any benefit at all.
In short: memoizing makes sense when the cost of recalculating outweighs the cost of maintaining the memoization, or when avoiding a re-render has a real, measurable impact. In every other case, simpler code usually wins.
Conclusion
I hope this article helped you better understand how memoization works in React and when it's actually useful to apply it. Memoization is a powerful tool, but like any tool, it needs to be used with good judgment. At the same time, the React 19 compiler is a great ally, but it needs well-written code to be able to optimize it properly.
If you want to put what you've learned to the test and combine memoization with context providers, check out this other video by Jack Herrington, where he builds a reusable library for managing global state.