
Choosing the right State Management solution
In the dynamic world of React development, managing application state effectively is crucial for building responsive, maintainable, and scalable user interfaces.
As applications grow in complexity, simply passing props down the component tree (prop drilling) becomes cumbersome and inefficient. This is where dedicated state management solutions come into play, offering structured and optimized ways to handle data that needs to be shared across different parts of your application.
React Context -> Zustand
The react context is a react primitive so we should generally use it up till the point that its not suitable anymore. React Context can become inefficient, primarily due to performance issues with frequent updates and difficulties in managing complex state. Zustand offers a more optimized and streamlined solution in such scenarios.
Here’s a breakdown of where React Context falls short and why Zustand becomes a more suitable choice:
Performance Bottlenecks with Frequent Updates 🐌
React Context has a fundamental characteristic: when the context value changes, all components consuming that context will re-render, regardless of whether they are interested in the specific piece of data that changed.
This problem is mitigated in signal-based frameworks with their context primitive equivalents, unfortunately these frameworks never receive the wide adoption that react does and fall short in many areas as a result. I’m personally a fan of Solid js but it always end being swapped out for React in my apps that have shipped.
If we were to use React Compiler (still a release candidate) our state would be automatically memoized and re-renders would be cheaper. This however doesn’t eliminate the problem, instead it allows us to push React Context further before being forced into using an alternative.
When the value
prop of a <Context.Provider>
changes, React itself will re-render all components that consume that specific context. The compiler’s job is then to make these re-renders as efficient as possible, potentially bailing out of rendering work for individual components if their props and relevant context slices haven’t effectively changed. However, the initial trigger is still a change in the entire context value. If you have a large context object and only a small, unrelated part of it changes, all consumers are still flagged for potential re-render.
Zustand uses a selector-based subscription model. Components explicitly subscribe to specific slices of the state. A component will only re-render if the return value of its selector function changes. For example: const x = useStore(state => state.x);
If state.y
changes in the Zustand store, but state.x
remains the same, the component above will not re-render.
Example: High-Frequency State Changes
Imagine you’re tracking the mouse position globally in your application using Context:
// src/context/...
interface MousePosition {
x: number;
y: number;
}
const MouseContext = createContext<MousePosition | undefined>(undefined);
interface Props {
children: ReactNode;
}
export const MouseProvider = ({ children }: Props) => {
const [mousePosition, setMousePosition] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return (
<MouseContext.Provider value={mousePosition}>
{children}
</MouseContext.Provider>
);
};
export const useMousePosition = (): MousePosition => {
const context = useContext(MouseContext);
if (context === undefined) {
throw new Error('useMousePosition must be used within a MouseProvider');
}
return context;
};
// src/components/component-a.tsx
// Only needs the x-coordinate
const ComponentA = () => {
const { x } = useMousePosition();
console.log('ComponentA re-rendered');
return <div>Mouse X: {x}</div>;
};
// src/components/component-b.tsx
// Only needs the y-coordinate
const ComponentB = () => {
const { y } = useMousePosition();
console.log('ComponentB re-rendered');
return <div>Mouse Y: {y}</div>;
};
// src/app.js
const App = () => (
<MouseProvider>
<ComponentA />
<ComponentB />
{/* Potentially many other components consuming the mouse context */}
</MouseProvider>
);
In this scenario, every time the mouse moves, both ComponentA
and ComponentB
(and any other component consuming MouseContext
) will re-render, even if ComponentA
only cares about the x
coordinate and ComponentB
only about the y
. If these components are complex or there are many of them, this leads to significant performance degradation.
How Zustand Helps:
Zustand allows components to subscribe to specific slices of the state. A component will only re-render if the particular piece of state it’s subscribed to changes.
// src/store/...
interface MousePosition {
x: number;
y: number;
}
interface MouseState {
mousePosition: MousePosition;
setMousePosition: (x: number, y: number) => void;
}
export const useMouseStore = create<MouseState>((set) => ({
mousePosition: { x: 0, y: 0 },
setMousePosition: (x, y) => set({ mousePosition: { x, y } }),
}));
// src/context/...
// Place in related provider, it will not change the value of the provider, therefore will not cause a rerender in said provider
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
useMouseStore.getState().setMousePosition(event.clientX, event.clientY);
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
// src/components/component-a.tsx
// Only needs the x-coordinate
const ComponentA = () => {
const x = useMouseStore((state) => state.mousePosition.x);
console.log('ComponentA re-rendered (Zustand)');
return <div>Mouse X (Zustand): {x}</div>;
};
// src/components/component-b.tsx
// Only needs the y-coordinate
const ComponentB = () => {
const y = useMouseStore((state) => state.mousePosition.y);
console.log('ComponentB re-rendered (Zustand)');
return <div>Mouse Y (Zustand): {y}</div>;
};
const App = () => (
<Provider>
<ComponentA />
<ComponentB />
</Provider>
);
export default App;
With Zustand, ComponentA
will only re-render if mousePosition.x
changes, and ComponentB
only if mousePosition.y
changes (though in this specific mouse example, they often change together). However, if the store held other unrelated state, components subscribing only to mouse position wouldn’t re-render if that other state changed. This selective re-rendering is a major performance win.
As you may have already noticed, we use a React Context provider in this example. In no way is this contradictory to the example because Zustand is a minimal store mechanism, it has no way creating providers.
In the same way that Zustand is exclusively a store mechanism, React Context is exclusively a provider mechanism, its concept of a store is essentially just derived state using the other React primitives (such as useState).
Managing Complex State and Boilerplate 🧩
As application state grows in complexity, managing it with React Context can become cumbersome. You might end up with:
Multiple Contexts: To avoid the re-render issue mentioned above, you might split your state into many small contexts. This can lead to a deeply nested tree of providers, making the component hierarchy harder to manage and understand (often referred to as “Provider Hell”).
Boilerplate for Updates: For each piece of state in Context, you typically need to define the state itself (useState
or useReducer
) and the functions to update it, then pass both down through the value
prop. This can become repetitive.
Derived State and Logic: Managing logic that derives new state from existing state or handles complex update sequences can be less straightforward with Context alone.
Zustand’s benefits here include:
Reduced Boilerplate: Less code to set up and manage state.
Collocated Logic: State and the functions that update it are defined together in the store.
Selective Subscriptions: Components only re-render if the specific state slices they depend on change.
Computed/Derived State: Easily define functions within the store (like totalPrice
and discountedPrice
) that compute values from the state. Components can subscribe to these derived values.
No Provider Hell: Zustand doesn’t require wrapping your application in Provider components in the same way Context does, simplifying the component tree.
Framework agnostic: Store logic shouldn’t depend on the JS framework you’re using. This way we can swap out our existing JS with any we like while still maintaining all core functionality described in our stores
State Outside React Components: Zustand’s state can be accessed and modified from outside React components (e.g., in utility functions, event handlers not tied to a component lifecycle), which is harder to achieve in a clean manner with Context.
Middleware: Zustand supports middleware for things like logging, persistence (e.g., saving to localStorage
), and handling asynchronous actions more elegantly.
Simpler Async Operations: Managing asynchronous operations within Zustand actions is often more straightforward than orchestrating them with useEffect
and Context updates. Though you should almost certainly be using Tanstack Query, unless you are in some kind of bizarre resource constrained environment
Helpers: We can even prevent re-renders in React using the Zustand useShallow helper
Testing: Testing Zustand stores can be simpler as they are plain JavaScript objects and functions, often not requiring a React rendering environment for unit testing the store logic.
In summary, while React Context is excellent for simpler prop-drilling avoidance and managing relatively static or infrequently changing global state (like themes, user authentication status), Zustand becomes a more compelling option when dealing with frequent state updates, complex state interactions, the need for optimized performance through selective re-renders, and a desire for less boilerplate and a more centralized state management approach.
Zustand -> Redux
While Zustand offers a simpler and more modern approach, a primary reason a team might still opt for Redux in a very complex scenario (like the large-scale financial trading platform example) often boils down to deep team familiarity and existing investment in the Redux ecosystem.
Here’s why that familiarity becomes a deciding factor:
Leveraging Existing Expertise: If a team is already highly proficient in Redux, Redux Saga/Thunk, Reselect, and its specific patterns for debugging and structuring large applications, the learning curve and development speed for a complex project might actually be faster with Redux than introducing Zustand, despite Zustand’s inherent simplicity. They can immediately leverage their existing knowledge to tackle complex asynchronous flows, intricate state logic, and utilize advanced debugging tools they are already masters of.
Established Tooling and Workflows: Large projects in established organizations often have existing tooling, CI/CD pipelines, testing strategies, and internal best practices built around Redux. Shifting away from this for a mission-critical, complex application can be a significant undertaking, and sticking with the familiar Redux ensures continuity.
Perceived Safety and Maturity for Extreme Complexity: For extremely complex, high-stakes applications, the battle-tested nature of Redux and its vast ecosystem provide a sense of stability and a wealth of resources. Teams familiar with it know how to navigate its complexities and trust its capabilities for demanding requirements like sophisticated side-effect management and stringent auditing, which they are already equipped to implement within the Redux paradigm.
So, in essence, for situations demanding the utmost in complex asynchronous operations, detailed traceability, and strict architectural control across large teams, a team already deeply familiar with Redux will naturally see it as the more powerful and reliable option. They are already equipped to harness its extensive features effectively, making familiarity a decisive practical advantage, even if Zustand could technically handle parts of the problem.
Zustand -> XState/Store
This will serve as more of an honorable mention for those who might fit the XState niche.
@xstate/store
offers a simpler, more traditional state management approach compared to the full XState library, which is designed for complex state machines and orchestrating application-wide logic.
Here’s why it gets a mention:
Different Pattern: It provides a more focused, observable store pattern that might be preferable for developers who find full XState to be overkill for simpler state needs but still want to stay within that ecosystem.
Easier Migration to Full XState: If you anticipate needing the power of XState for more complex state orchestration later in your current project or in future projects, starting with @xstate/store
can provide a smoother transition. The mental model and some core concepts align, making the jump less jarring.
Familiarity for XState Users: For developers already comfortable with XState’s concepts and terminology, @xstate/store
will feel more intuitive and require less of a learning curve than adopting an entirely new state management paradigm.
Why Zustand is Generally Considered the Better Choice
Despite the benefits of @xstate/store
in specific contexts, Zustand often comes out ahead for a broader range of use cases due to several key advantages:
👍 Easier to Use and Understand: Zustand is renowned for its simplicity and minimal boilerplate. Its API is intuitive, making it very approachable for developers of all levels. You can get up and running with Zustand quickly, and its core concepts are generally easier to grasp than even the simplified @xstate/store
.
📚 Better Documented and More Widely Used: Zustand boasts excellent documentation that is clear, comprehensive, and full of practical examples. Its widespread adoption means a larger community, more available resources (tutorials, articles, Q&A), and a greater likelihood of finding solutions to common problems.
✨ Legendary Maintainers: Zustand is backed by maintainers from Poimandres, a collective known for high-quality, innovative open-source libraries in the JavaScript ecosystem. This inspires confidence in the library’s longevity, stability, and continued development.
🚀 Performance: Zustand is designed with performance in mind, often leading to more optimized re-renders out-of-the-box without complex configurations.
🧩 Flexibility: While simple, Zustand is also very flexible. It doesn’t impose a rigid structure, allowing developers to organize their stores in a way that best suits their project’s needs.
🪶 Lightweight: It has a very small bundle size, which is always a plus for web performance.
In essence, while @xstate/store
serves a valuable niche, especially for those committed to the XState ecosystem, Zustand’s combination of simplicity, strong community support, excellent documentation, and ease of use makes it a more generally applicable and often preferred solution for state management in modern JavaScript applications.
Wrapping things up
Feature | React Context API | Zustand | Redux (Redux Toolkit) | XState/Store |
---|---|---|---|---|
Core Concept | Built-in mechanism for prop drilling avoidance; shares state down the component tree. | Minimalist, hook-based state management; unopinionated and flexible. | Predictable state container with a centralized store, actions, and reducers (simplified by Toolkit). | Event-driven state management based on finite state machines and statecharts; focuses on explicit states and transitions. |
Primary Use Case | Simple global state (e.g., theming, user auth), avoiding prop drilling in small to medium apps. | Small to large apps needing a simple, performant global or local state solution without much boilerplate. | Large-scale applications with complex state, frequent updates, and need for robust debugging and middleware. | Applications with complex, explicit state logic, workflows, and sequences (e.g., forms, UI flows, business logic). Ideal for managing behavioral state. |
Boilerplate | Minimal (createContext, Provider, useContext). | Very Low (create store hook). | Low to Medium (with Redux Toolkit: configureStore , createSlice ). Significantly less than classic Redux. | Low for @xstate/store (similar to Zustand for basic stores). Higher for full XState machines due to declarative state definitions. |
Learning Curve | Low (React knowledge assumed). | Low (familiarity with React hooks). | Medium (Redux principles like actions, reducers, immutability, even with RTK). | Medium to High (requires understanding state machine concepts, event-driven programming; @xstate/store is simpler). |
Performance | Can cause re-renders of all consumers if not optimized (e.g., with memo or splitting contexts). Prone to issues with frequent updates. | High; optimized for selective re-renders out-of-the-box. Components subscribe to specific parts of the state. | Good, especially with Redux Toolkit and memoized selectors (reselect ). Centralized store can be optimized. | Good for @xstate/store with selectors. Full XState performance depends on machine complexity; generally efficient due to explicit state transitions. |
Immutability | Not enforced by default; manual management or use with useReducer . | Encouraged; updates typically create new state objects. Supports Immer via middleware. | Enforced (Redux Toolkit uses Immer by default in createSlice ). | Encouraged for context data; Immer can be used. State transitions inherently create new state snapshots. |
Async Operations | Managed manually (e.g., useEffect with Workspace ). | Handled via functions in the store; can integrate with async middleware. | Built-in createAsyncThunk in Redux Toolkit simplifies async logic. Rich middleware ecosystem (e.g., Thunks, Sagas). | Handled via services, invocations, and effects within state machine definitions. Very powerful for complex async flows. @xstate/store allows async effects. |
DevTools | React DevTools show context values. No specific state management devtools. | Can integrate with Redux DevTools. | Excellent Redux DevTools support for time-travel debugging, action inspection, etc. | XState Visualizer for machines. @xstate/store can use Redux DevTools. |
Ecosystem & Community | Part of React; large community. | Growing rapidly; good community support. | Very Large and mature; extensive libraries, middleware, and community resources. | Strong, especially around statecharts and complex state orchestration. @xstate/store is newer but benefits from XState community. |
Bundle Size (approx. min+gz) | 0 (Built-in) | ~1-4KB | ~8-15KB (Redux Toolkit + React-Redux) | @xstate/store : <1KB. Full XState: ~15-20KB. |
Key Differentiator | Simplicity for sharing global data without external libraries. | Simplicity, minimal API, performance with selective re-renders, less boilerplate. | Predictability, strong conventions, powerful devtools, extensive middleware. | Modeling complex UIs and logic as state machines, ensuring predictable behavior and clear state transitions. Strong focus on how state changes. |
When to Choose | When you have simple global data that doesn’t change too frequently, or to avoid prop drilling in smaller apps. | When you want a simple, fast, and scalable solution with minimal boilerplate for medium to large apps. | For large, complex applications requiring a robust, predictable state architecture, advanced debugging, and a rich middleware ecosystem. | When dealing with complex, sequential, or event-driven state logic (e.g., multi-step forms, game states, intricate UI interactions). @xstate/store for simpler event-driven stores. |
In other words
React Context: Best for simpler, low-frequency global state or avoiding prop drilling. Performance can be a concern with frequent updates if not carefully managed.
Zustand: Offers a sweet spot of simplicity, low boilerplate, and excellent performance. It’s highly flexible and scales well for many applications.
Redux (with Redux Toolkit): The standard for complex, large-scale applications where predictability, ease of debugging, and a rich ecosystem are paramount. Redux Toolkit has significantly reduced the boilerplate associated with classic Redux.
XState (full library): Primarily for modeling complex application logic as state machines. It excels where the behavior and transitions between states are critical. It brings formality and visual tooling to complex flows.
@xstate/store: Much simpler, lightweight companion for event-driven state management, comparable to Zustand in its simplicity for basic stores. Event-based update model aligned with XState’s principles.
Choosing the right tool depends heavily on your project’s complexity, team familiarity, performance requirements, and the nature of the state you need to manage.