Managing state in modern frontend applications can quickly become complex, especially as your app grows in size. Developers often turn to state management libraries like Redux or MobX to handle global state across components. While these libraries offer extensive features, they can also add unnecessary complexity and overhead to smaller projects. Enter Zustand, a lightweight state management library that is gaining popularity for its simplicity and performance.
In this article, we will explore how to use Zustand for lightweight state management in your React applications. We’ll cover everything from setting up Zustand in your project, to managing global state, and even optimizing performance. Zustand’s API is incredibly simple, making it a perfect choice for developers who need state management without the overhead of larger libraries.
Why Choose Zustand?
Zustand, which means “state” in German, is a minimalistic state management library designed for React. It’s known for its lightweight footprint, intuitive API, and the ability to scale without introducing too much complexity. Here are some reasons to consider using Zustand:
Simplicity: Zustand offers an easy-to-understand API that lets you manage state without learning complex concepts like reducers, selectors, or middlewares.
Performance: Zustand only re-renders the components that depend on the changed part of the state, ensuring optimal performance even in larger applications.
Minimal Boilerplate: You don’t need to deal with actions, reducers, or type definitions. Zustand keeps your code concise and readable.
Scalability: While Zustand is lightweight, it’s also flexible enough to handle complex state logic as your application grows.
Let’s dive into how to implement Zustand for state management in a React app.
Setting Up Zustand
Before we can use Zustand in a React project, we need to install it. Zustand is available via npm or yarn, making it simple to integrate into any project.
Installation
To get started, install Zustand using npm or yarn:
npm install zustand
or
yarn add zustand
Once installed, you’re ready to create your first store and start managing state in your application.
Creating Your First Store
In Zustand, state is stored in stores. A store is essentially a collection of state variables and functions that manipulate the state. Zustand allows you to define these stores using a simple API that directly maps to the needs of your components.
Let’s start with a basic example of creating a store to manage a simple counter.
Example: Counter Store
import create from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
Here’s a breakdown of the store creation:
create
is a function provided by Zustand that allows you to define the initial state and actions for your store.set
is used to update the state, similar to React’ssetState
. It takes a callback that receives the current state and returns the updated state.- The store contains a state variable
count
and two functions:increment
anddecrement
to modify the count.
Using the Store in Components
Now that we’ve created a store, let’s see how we can use it in a React component.
import React from 'react';
import { useCounterStore } from './store'; // Import the store
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}
export default Counter;
In this example:
We use the store by calling useCounterStore
with a selector function to access specific parts of the state (e.g., state.count
, state.increment
).
The component re-renders only when the selected state (count
) changes, keeping it highly performant.
Why This Works
One of the reasons Zustand stands out is its selector-based access. By passing a selector function to useCounterStore
, you ensure that only the relevant state is retrieved. This prevents unnecessary re-renders of components when other, unrelated state changes.
For example, if the store had other properties (like user information or theme settings), changing those wouldn’t affect the Counter
component since it only depends on the count
value.
Managing Complex State with Zustand
While Zustand shines with simple state management, it’s also more than capable of handling complex, nested state. You can easily manage more sophisticated application state by combining multiple stores or adding more logic to a single store.
Example: Todo List Store
Let’s create a more complex store to manage a list of todos. This store will allow you to add, toggle, and remove todos.
import create from 'zustand';
const useTodoStore = create((set) => ({
todos: [],
addTodo: (todo) =>
set((state) => ({
todos: [...state.todos, { text: todo, completed: false }],
})),
toggleTodo: (index) =>
set((state) => ({
todos: state.todos.map((todo, i) =>
i === index ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (index) =>
set((state) => ({
todos: state.todos.filter((_, i) => i !== index),
})),
}));
In this example:
- The store has an array of
todos
that holds each todo’s text and completion status. - The
addTodo
function adds a new todo to the array. - The
toggleTodo
function flips thecompleted
status of a todo at a specific index. - The
removeTodo
function deletes a todo from the array.
Using the Todo Store in Components
Now, let’s use this store in a component that displays and manages todos.
import React, { useState } from 'react';
import { useTodoStore } from './store'; // Import the store
function TodoApp() {
const todos = useTodoStore((state) => state.todos);
const addTodo = useTodoStore((state) => state.addTodo);
const toggleTodo = useTodoStore((state) => state.toggleTodo);
const removeTodo = useTodoStore((state) => state.removeTodo);
const [newTodo, setNewTodo] = useState('');
return (
<div>
<h1>Todo List</h1>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new todo"
/>
<button onClick={() => addTodo(newTodo)}>Add</button>
<ul>
{todos.map((todo, index) => (
<li key={index} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
<button onClick={() => toggleTodo(index)}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
<button onClick={() => removeTodo(index)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
In this component:
- We use
useTodoStore
to interact with the store’s state and functions. - Users can add new todos, toggle their completion status, and remove them from the list.
- The list dynamically updates as the state changes, reflecting the current state of the
todos
.
This example shows that Zustand scales well, even for applications requiring more complex state management.
Handling Middleware and Side Effects
Zustand also supports middleware, allowing you to intercept and manage state updates. This can be useful for logging, validating state changes, or persisting state to local storage.
Example: Logging Middleware
Let’s create a simple middleware to log every state change:
import create from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(devtools((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
})));
In this example:
The devtools
middleware is used to log state changes to the browser’s console. Every time the state changes, the new state is logged, helping you debug and track how your application’s state evolves.
You can also use Zustand’s persist
middleware to automatically save state to local storage, ensuring that users’ data persists across sessions.
Example: Persisting State
import create from 'zustand';
import { persist } from 'zustand/middleware';
const usePersistentStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}),
{ name: 'counter-storage' } // Unique key for local storage
)
);
In this case, Zustand automatically saves the count
state to local storage under the key counter-storage
. The state will persist even if the user refreshes the page.
Optimizing Performance with Zustand
Zustand is designed with performance in mind. It automatically optimizes component re-renders, but there are still a few best practices to ensure you get the most out of Zustand.
1. Use Selectors to Minimize Re-renders
As seen in the previous examples, Zustand allows you to select specific parts of the state, ensuring that your components only re-render when the selected state changes. This fine-grained control over re-renders is crucial for performance in larger applications.
For example, if your component only needs access to the count
value, use a selector to retrieve just that part of the state:
const count = useCounterStore((state) => state.count);
This ensures that the component will not re-render if other parts of the store (e.g., unrelated todos or user data) change.
2. Avoid Unnecessary State Changes
Zustand provides a clear and simple API for updating state, but it’s still important to avoid unnecessary state changes that can trigger unwanted re-renders. Only update state when it’s actually necessary.
For example, before updating state, check if the new value is different from the current value:
set((state) => {
if (state.count === newCount) return state; // No update needed
return { count: newCount };
});
Zustand vs. Redux: When to Choose Which
While Zustand and Redux both handle state management, they serve different use cases. Here’s a quick comparison:
Zustand: Ideal for small to medium-sized applications that need state management without the complexity of Redux. It’s lightweight, requires minimal boilerplate, and integrates seamlessly into React projects.
Redux: Best suited for larger applications that require more extensive state management features, like time travel debugging, middleware, and strict state immutability.
If you’re building a project that doesn’t require Redux’s level of complexity, Zustand is likely the better choice due to its simplicity and performance optimizations.
Advanced Zustand Features for Scaling Applications
While Zustand is well-suited for small to medium projects due to its simplicity, it also provides advanced features that help scale applications as they grow. In this section, we’ll explore some of these advanced capabilities, including working with derived state, handling multiple stores, and integrating Zustand with TypeScript for better type safety.
1. Derived State with Zustand
Derived state refers to state that depends on other parts of the state and is automatically updated when its dependencies change. This is useful when you need to calculate new values based on the existing state without manually updating them every time the state changes.
Zustand doesn’t have built-in derived state like other libraries (such as Redux selectors), but you can easily implement it using JavaScript functions inside the store.
Example: Derived State for Calculating Total Price in a Cart
import create from 'zustand';
const useCartStore = create((set) => ({
cart: [],
addItem: (item) => set((state) => ({ cart: [...state.cart, item] })),
removeItem: (id) => set((state) => ({ cart: state.cart.filter(item => item.id !== id) })),
// Derived state: calculate total price
totalPrice: (state) => state.cart.reduce((sum, item) => sum + item.price, 0),
}));
Here, the totalPrice
is calculated dynamically based on the cart
state. You can use this derived state in your components without storing it explicitly:
import React from 'react';
import { useCartStore } from './store';
function CartSummary() {
const cart = useCartStore((state) => state.cart);
const totalPrice = useCartStore((state) => state.totalPrice);
return (
<div>
<h1>Cart</h1>
<ul>
{cart.map((item) => (
<li key={item.id}>{item.name} - ${item.price}</li>
))}
</ul>
<p>Total Price: ${totalPrice}</p>
</div>
);
}
2. Handling Multiple Stores
As your application grows, you might want to split your state across multiple stores for better organization. Zustand allows you to create multiple stores and combine them as needed.
For example, you could have separate stores for managing user authentication and application settings.
Example: Multiple Stores (User and Settings)
import create from 'zustand';
// User store
const useUserStore = create((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// Settings store
const useSettingsStore = create((set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));
These stores can be used separately in different components, or even together if needed:
import React from 'react';
import { useUserStore, useSettingsStore } from './stores';
function UserProfile() {
const user = useUserStore((state) => state.user);
const theme = useSettingsStore((state) => state.theme);
return (
<div className={`profile ${theme}`}>
{user ? <h1>{user.name}</h1> : <p>Please log in.</p>}
</div>
);
}
Splitting your application state across multiple stores keeps your code modular and helps manage complex applications with ease.
3. Zustand with TypeScript
For developers who prefer TypeScript, Zustand works seamlessly with it, providing type safety for your state and actions. TypeScript improves the developer experience by offering better autocompletion and error checking.
Here’s how you can type your Zustand stores in a TypeScript project:
Example: TypeScript Store
import create from 'zustand';
// Define types for the store
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
// Create the store with types
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
In this example, the CounterState
interface defines the shape of the state and actions. TypeScript now ensures that any usage of useCounterStore
will adhere to this structure, preventing errors and offering autocompletion in your editor.
When using the store in a component, TypeScript will provide type hints for the count
, increment
, and decrement
actions:
import React from 'react';
import { useCounterStore } from './store';
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}
4. Persisting State with Middleware
One of Zustand’s strengths is its support for middleware, which allows you to extend its functionality. One popular middleware is the persist
middleware, which lets you automatically save state to local storage or session storage.
Example: Persisting Theme State
import create from 'zustand';
import { persist } from 'zustand/middleware';
// Store with persistence
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'theme-storage' } // Key for localStorage
)
);
In this example, the theme state is automatically persisted to localStorage
. When the user reloads the page, the theme preference will be restored from localStorage
without any additional logic in your components.
This makes Zustand an excellent choice for applications that require persistent state, such as user preferences, shopping carts, or authentication tokens.
5. Handling Asynchronous State
Like other state management libraries, Zustand can handle asynchronous state updates, such as fetching data from an API. You can easily integrate async logic into your store’s actions using async/await.
Example: Async Data Fetching in Zustand
import create from 'zustand';
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (userId) => {
set({ loading: true });
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
set({ user: userData, loading: false });
} catch (error) {
set({ error: 'Failed to fetch user', loading: false });
}
},
}));
In this example, the fetchUser
function fetches data from an API, handles the loading and error states, and updates the store with the fetched user data. The component can then use this async logic without needing to manage any side effects manually:
import React, { useEffect } from 'react';
import { useUserStore } from './store';
function UserProfile({ userId }) {
const user = useUserStore((state) => state.user);
const loading = useUserStore((state) => state.loading);
const error = useUserStore((state) => state.error);
const fetchUser = useUserStore((state) => state.fetchUser);
useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
With Zustand, handling async state is as simple as integrating async functions directly into your store’s logic. This keeps your components clean and your state logic centralized.
Conclusion
Zustand offers a powerful yet minimalistic solution for state management in React applications. Its simple API, performance optimizations, and support for middleware make it an excellent choice for developers who want to manage global state without the complexity of larger libraries like Redux.
Whether you’re managing simple counters or complex application-wide state, Zustand provides the flexibility to scale without sacrificing simplicity. Its ability to manage state efficiently and with minimal boilerplate makes it a go-to tool for lightweight state management.
At PixelFree Studio, we specialize in creating high-performance web applications that are scalable and maintainable. If you’re looking to optimize your state management or need help building your next project, get in touch with us today to learn how we can assist you in building fast, reliable, and user-friendly applications!
Read Next: