Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back.
In our last lesson, we established a clear framework for why and when you should consider migrating from React Context to Zustand. We identified that features with frequently updated, complex, or broadly shared state are prime candidates, especially when performance issues like unnecessary re-renders become a concern.
Today, we move from theory to practice. This lesson is dedicated to the how. We will perform a hands-on refactoring of a component tree, converting it from a state management pattern based on prop drilling and React Context to a more streamlined and performant solution using a single global Zustand store.
This lesson directly addresses the learning outcome: Refactor a component tree from using prop drilling and React Context to using a single global Zustand store.
By the end of this session, you'll have walked through the entire refactoring process and understand the specific code changes required to leverage Zustand's core benefits.
The "Before" State: Our Starting Point
To make this practical, let's establish a common scenario. Imagine a simple task management feature within a larger application. This feature needs to share state—like the list of tasks, the current filter, and the UI view—across several components (a header, a list, a filter bar, etc.).
Initially, this might be handled by "lifting state up" to a common ancestor and passing it down via props. As we discussed, this leads to prop drilling.
React State Management in 2025: What You Actually Need
As a quick refresher, let's revisit the concept of prop drilling and how React Context is positioned as the native solution. This article provides a concise example.
Briefly review the sections 'Shared State and Props Drilling' and the beginning of 'Shared State and Context'. Focus on the code examples that show state being passed down through multiple components, and how a Context Provider is introduced to solve this.
The standard solution to prop drilling is to use React Context. A typical implementation involves three parts:
- Creating the context with
createContext. - Creating a
Providercomponent that holds state (usinguseStateoruseReducer) and wraps the part of the component tree that needs access. - Creating a custom hook that uses
useContextfor easy consumption in child components.
The following resource provides a complete, self-contained example of this pattern, which will serve as the "before" state for our refactoring exercise.
Introducing Zustand (State Management)
This article from the Frontend Masters blog, 'Introducing Zustand (State Management)', sets up the exact scenario we'll be working with. It demonstrates building a task management feature using only React Context.
Please read the section 'The Vanilla Version'. Pay close attention to the structure of the TasksProvider component, how multiple useState hooks are used to manage the state, and how the useTasksContext hook is created and used. This is the code we are about to refactor.
As you've just read, this approach works, but it has the two key drawbacks we've discussed:
- Boilerplate: You have to create the context, the provider, manage state with hooks inside it, and create a consumer hook.
- Performance: Any update to any piece of state in the
TasksProvider(e.g., changingcurrentFilter) will cause every component usinguseTasksContextto re-render, even if that component only cares about thetasksarray.
Now, let's fix this.
The Refactoring Process: Migrating to Zustand
We will refactor the Tasks feature in three main steps.
Step 1: Create the Zustand Store
Our first task is to move the state logic out of the TasksProvider component and into a Zustand store. With Zustand, all state and the actions that modify it are defined together in a single create function.
The article you've been reading continues by showing exactly how to do this.
Introducing Zustand (State Management)
Let's see how to translate the state logic from our TasksProvider into a Zustand store.
Read the sections 'Introducing Zustand' and 'Updating state'. Notice how the useState calls are replaced by a single state object, and the state setter functions are replaced by actions that call Zustand's set function.
Let's break down what's happening in that create call:
export const useTasksStore = create(set => ({
// State
tasks: dummyTasks,
currentView: "list",
currentFilter: "",
// Actions
setTasks: (newTasks) => set({ tasks: newTasks }),
setCurrentView: (newView) => set({ currentView: newView }),
setCurrentFilter: (newFilter) => set({ currentFilter: newFilter }),
}));
- We call
createand pass it a function. This function receives asetargument, which is the mechanism for updating state. - We return an object that contains our entire state slice: both the data (
tasks,currentView) and the actions that can modify it (setTasks,setCurrentView). - Each action is a function that calls
setwith an object representing the state to be changed. By default, Zustand performs a shallow merge, so you only need to provide the fields that are being updated.
The result, useTasksStore, is a custom hook that is ready to be used anywhere in your application.
Step 2: Remove the React Context Provider
This is the simplest but most satisfying step. Since Zustand stores are not tied to the React component tree, we no longer need the <TasksProvider> wrapper.
You can go to your main application component (App.js or similar) and simply delete it.
Before:
<TasksProvider>
<Header />
<TaskBody />
</TasksProvider>
After:
<>
<Header />
<TaskBody />
</>
This immediately flattens your component tree and eliminates the "Provider Hell" problem we discussed.
Step 3: Update Components to Use the Zustand Store
Now we need to go into the components that were using useTasksContext and switch them to useTasksStore. However, how we do this is critically important for performance.
The Naive (and Incorrect) Approach
You might be tempted to do this:
// In a component...
const { currentView, tasks } = useTasksStore(); // AVOID THIS
This will work, but it completely defeats the purpose of migrating. The useTasksStore() hook, when called without arguments, returns the entire state object. Any change to any part of the store (e.g., currentFilter) creates a new state object, causing this component to re-render, just like with React Context.
The Correct Approach: Using Selectors
To unlock Zustand's performance benefits, you must use selectors. A selector is a function you pass to the store hook to extract only the specific pieces of state your component needs.
Introducing Zustand (State Management)
This is the most crucial part of the refactoring process. This section of the article explains how to use selectors to ensure components only re-render when necessary.
Read the section 'The Correct Way to Read State'. Focus on the difference between destructuring the entire store and using individual selectors. Also, note the use of useShallow for when you need to select multiple values.
Here is the principle in action. If a component only needs the currentView, you select it like this:
// In TaskHeader.js
const currentView = useTasksStore(state => state.currentView);
const setCurrentView = useTasksStore(state => state.setCurrentView);
Now, this component subscribes only to changes in currentView. If another component updates currentFilter, this TaskHeader component will not re-render. This is the power of selective subscriptions.
If you need multiple pieces of state, you can use useShallow to subscribe to a group of properties. The component will only re-render if one of those specific properties changes.
import { useShallow } from 'zustand/react/shallow';
// In TaskList.js
const { tasks, currentFilter } = useTasksStore(
useShallow(state => ({
tasks: state.tasks,
currentFilter: state.currentFilter,
}))
);
By applying this selector pattern to all components that previously used useTasksContext, our refactoring is complete.
The "After" State: A Cleaner, Faster Solution
Let's compare a component before and after the refactor.
Before (React Context):
const TaskList = () => {
const { tasks, currentFilter } = useTasksContext();
// ... filtering logic ...
return (
// ... JSX to render tasks ...
);
};
After (Zustand with Selectors):
import { useShallow } from 'zustand/react/shallow';
const TaskList = () => {
const { tasks, currentFilter } = useTasksStore(
useShallow(state => ({
tasks: state.tasks,
currentFilter: state.currentFilter,
}))
);
// ... filtering logic ...
return (
// ... JSX to render tasks ...
);
};
The component code looks very similar, but the underlying mechanics are vastly different and more efficient. We have achieved:
- Reduced Boilerplate: The entire Context and Provider implementation is gone.
- A Flatter Component Tree: No more
<TasksProvider>wrapper. - Optimal Performance: Components now only re-render when the specific data they care about has changed.
A Note on Advanced Patterns
While our focus today is on replacing Context with a global Zustand store, it's worth noting that Zustand can also be used to manage non-global, instance-specific state. The video "Zustand with Context API – An Advanced Pattern" demonstrates a technique where a Zustand store is created inside a component and then provided via React Context. This is useful for building reusable, stateful components (like a video player or a complex widget) where each instance needs its own isolated state. We won't dive deep into it, but it's a powerful pattern to be aware of that shows the flexibility of Zustand's architecture.
Conclusion
Today you've successfully walked through a complete refactoring process, migrating a feature from a standard React Context implementation to a global Zustand store. This is a fundamental skill for applying Zustand in existing projects.
Key Takeaways:
- Refactoring is a 3-step process:
- Create the Store: Consolidate state logic from
useState/useReducerinto a singlecreatecall. - Remove the Provider: Simplify the component tree by removing the Context Provider wrapper.
- Update Components with Selectors: Replace
useContextcalls with the Zustand store hook, using selectors to subscribe only to the necessary state slices.
- Create the Store: Consolidate state logic from
- Selectors are non-negotiable for performance. The key to Zustand's efficiency is moving from a model where the provider pushes the entire state to all consumers, to a model where each component pulls only the data it needs via a selector.
- The result is a codebase that is simpler, has less boilerplate, and is significantly more performant due to the elimination of unnecessary re-renders.
In our next lesson, we'll address a common real-world challenge: gradual migration. We will explore how to design a coexistence pattern that allows components using your new Zustand store to interoperate with parts of the application that still rely on React Context.