Recommended resources
The freely available resources this lesson will direct you to.
Hello! Let's dive into our next lesson on Zustand performance.
In our last session, we tackled the problem of unnecessary re-renders when selecting object or array slices. You learned how to use the shallow equality function to ensure components only update when the actual data they depend on has changed, solving the challenge of referential identity.
Today, we'll build on that knowledge to handle a very common scenario: derived state. This lesson addresses the learning outcome: Implement derived state computations directly within component selectors and identify scenarios that cause unnecessary re-renders.
We will cover:
- What derived state is and how to compute it in a selector.
- The two key performance considerations: unnecessary computation and unnecessary re-renders.
- How to distinguish between these two issues and use
shallowto solve the re-render problem for derived data.
1. What is Derived State?
Often, the data a component needs isn't stored directly in your state but is calculated from it. This is called derived state.
For example, imagine a to-do list store:
const useTodoStore = create((set) => ({
todos: [
{ id: 1, text: 'Learn Zustand', completed: true },
{ id: 2, text: 'Build an app', completed: false },
{ id: 3, text: 'Ship it!', completed: false },
],
// ... other state like filters, user, etc.
}));
A component might need to display the number of completed to-dos. This count is derived from the todos array. Instead of storing completedCount in the state (which would be redundant and require manual syncing), we can calculate it on the fly.
The most direct way to do this is within a component selector:
function CompletedTodosCounter() {
const completedCount = useTodoStore(
(state) => state.todos.filter((todo) => todo.completed).length
);
return <div>Completed: {completedCount}</div>;
}
This works, but it introduces two potential performance issues that we need to understand and manage.
2. The Two Problems: Computation vs. Re-rendering
When you compute derived state inside a selector, you must consider two distinct performance aspects.
Problem 1: Unnecessary Computation
A fundamental characteristic of Zustand is that selectors run every time any part of the store changes, not just when the data they select changes.
Zustand vs. Signals — Comparing React State ...
The article 'Zustand vs. Signals' provides an excellent, concise explanation of this behavior. Understanding this is key to grasping the performance implications of derived state.
Read the section titled 'Performance with Zustand'. Focus on the part that contrasts Zustand's selectors with Signals, specifically the note that starts with 'One slight disadvantage I noticed...'.
As the article points out, the filter() operation in our CompletedTodosCounter example will execute every single time any value in the store is updated. If your store also contained a theme property and the user toggled dark mode, the todos array would be filtered again, needlessly.
For a small array, this is trivial. For a large dataset or a more complex calculation (e.g., mapping, sorting, and filtering), this repeated, unnecessary work can become a performance bottleneck.
Problem 2: Unnecessary Re-renders
This second problem occurs when your derived state is a new object or array. This should sound familiar from our last lesson.
Consider a component that displays the list of incomplete to-dos:
function IncompleteTodoList() {
// .filter() ALWAYS returns a new array
const incompleteTodos = useTodoStore(
(state) => state.todos.filter((todo) => !todo.completed)
);
return (
<ul>
{incompleteTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
);
}
Here's the breakdown of what goes wrong:
- Unnecessary Computation: The
.filter()method runs on every store update. - Unnecessary Re-render: The
.filter()method returns a new array instance every time. Because this new array fails the default strict equality check (===) against the previous result, Zustand assumes the state has changed and triggers a re-render of the component—even if the list of incomplete to-dos is identical.
This is the worst-case scenario: you're doing expensive work on every update, and it's causing your component to re-render on every update as well.
3. Optimizing Derived State Selectors
Now, let's address these issues. We can solve the re-render problem using the shallow function we learned about previously.
Let's look at a more complex example that derives multiple values.
Zustand vs. Signals — Comparing React State ...
The 'Zustand vs. Signals' article again provides a perfect, practical example of deriving state within a component and using shallow to optimize it.
Read the section 'Deriving State' and analyze the InfoBar component example. Pay close attention to how the selector returns an array of values and uses shallow as the second argument.
Let's break down the code from the article:
import { shallow } from 'zustand/shallow';
export const InfoBar = memo(() => {
const [lastUpdated, totalCompleted] = useTodoStore(
(state) => [
state.lastUpdated, // A primitive value
state.todos.filter((todo) => todo.completed).length, // A derived primitive
],
shallow // The key to optimizing re-renders
);
// ... render logic
});
Here's what's happening:
- The selector runs on every store update, calculating the
totalCompletedcount each time. (The computation problem still exists). - It returns an array, e.g.,
['2:45 PM', 5]. - The
shallowfunction compares the previous array with the new one. It checks each element:prev[0] === next[0]andprev[1] === next[1]. - The
InfoBarcomponent will only re-render ifstate.lastUpdatedchanges or if the calculatedtotalCompletedlength changes.
By using shallow, we have successfully prevented unnecessary re-renders. The component is now correctly optimized to update only when its displayed data changes. However, we have not solved the unnecessary computation problem. The filter operation still runs on every single store update.
Summary of Scenarios
Let's summarize the behavior for different types of derived state selectors.
| Scenario | Computation Behavior | Re-render Behavior |
|---|---|---|
1. Derived PrimitiveuseStore(state => state.items.length) |
Runs on every store update (usually trivial). | Optimal. Only re-renders if the primitive value (length) actually changes. |
2. Derived Array/Object (No shallow)useStore(state => state.items.filter(…)) |
Runs on every store update (potentially costly). | Problem! Re-renders on every store update because a new array is created. |
3. Derived Array/Object (With shallow)useStore(state => state.items.filter(…), shallow) |
Runs on every store update (still potentially costly). | Optimal. Only re-renders if the contents of the derived array/object change. |
Conclusion
You now know how to implement derived state calculations within your component selectors and, critically, how to analyze their performance.
Key Takeaways:
- Derived state is any value computed from your store's state.
- Selectors that compute derived state run on every store update, which can lead to unnecessary, expensive calculations.
- If a selector derives a new array or object, it will cause unnecessary re-renders on every store update unless you use an equality checker like
shallow. - Using
shallowsolves the re-render problem for derived objects/arrays but does not solve the underlying unnecessary computation problem.
We have successfully identified and partially solved the challenges of derived state. But what about that lingering "unnecessary computation" problem? For truly expensive calculations, we need a way to prevent the calculation from running at all if its dependencies haven't changed.
In our next lesson, we will solve this final piece of the puzzle by integrating reselect to create efficient, memoized selectors for complex derived data.