Next.js Zustand
Introduction
State management is a critical aspect of building modern web applications, especially as they grow in complexity. While Next.js provides some built-in solutions for handling state, many developers turn to third-party libraries for more sophisticated state management patterns. Zustand is one such library that has gained popularity due to its simplicity, performance, and flexibility.
In this tutorial, we'll explore how to use Zustand in a Next.js application to manage application state effectively. By the end, you'll understand how to implement Zustand in your Next.js projects and leverage its capabilities for clean, efficient state management.
What is Zustand?
Zustand (German for "state") is a small, fast, and scalable state management solution. Created by the makers of react-three-fiber, it provides a minimalistic API that focuses on ease of use without sacrificing power or flexibility.
Key benefits of Zustand include:
- Simplicity: The API is straightforward and has minimal boilerplate
- Hooks-based: Works seamlessly with React's Hooks API
- Lightweight: Tiny package size (~1KB)
- No providers needed: Unlike Context or Redux, no need to wrap your app in providers
- TypeScript support: Built with TypeScript for type safety
- DevTools support: Works with Redux DevTools for debugging
Getting Started with Zustand in Next.js
Installation
First, let's install Zustand in our Next.js project:
npm install zustand
# or
yarn add zustand
# or
pnpm add zustand
Creating Your First Store
Let's create a simple counter store to understand the basics of Zustand. Create a new file in your Next.js project named stores/useCounterStore.js
:
import { create } from 'zustand';
// Create a store with an initial state and actions
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementByAmount: (amount) => set((state) => ({ count: state.count + amount })),
}));
export default useCounterStore;
In this example:
- We import
create
from Zustand - We define our store with an initial state (
count: 0
) - We define actions that can update the state (
increment
,decrement
,reset
, andincrementByAmount
) - Each action uses the
set
function to update the state
Using the Store in Components
Now, let's use our store in a Next.js component:
import useCounterStore from '@/stores/useCounterStore';
export default function Counter() {
// Extract only what you need from the store
const { count, increment, decrement, reset } = useCounterStore();
return (
<div className="counter">
<h2>Counter: {count}</h2>
<div className="buttons">
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
</div>
);
}
Output: A counter UI with buttons that increment, decrement, and reset the counter value.
Selective Subscription
One of the advantages of Zustand is its ability to selectively subscribe to parts of the state, preventing unnecessary re-renders:
// Only re-render when count changes
const count = useCounterStore((state) => state.count);
// Only re-render when increment function reference changes
const increment = useCounterStore((state) => state.increment);
Advanced Zustand with Next.js
TypeScript Integration
Zustand works great with TypeScript. Here's how to type your store:
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementByAmount: (amount: number) => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementByAmount: (amount) => set((state) => ({ count: state.count + amount })),
}));
export default useCounterStore;
Middleware Support
Zustand supports middleware, allowing you to extend its functionality. For example, adding persistence to localStorage:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useTodoStore = create(
persist(
(set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter(todo => todo.id !== id)
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
}),
{
name: 'todo-storage', // unique name for localStorage key
storage: createJSONStorage(() => localStorage), // use localStorage
}
)
);
export default useTodoStore;
Note: When using localStorage in Next.js, make sure to handle server-side rendering properly by checking if
window
is defined:
const storage =
typeof window !== 'undefined'
? createJSONStorage(() => localStorage)
: undefined;
Zustand with Next.js Server Components
Next.js 13+ introduced the App Router with React Server Components. Since Zustand is a client-side state management library, you need to use it within client components:
'use client'; // Mark as client component
import useCounterStore from '@/stores/useCounterStore';
export default function CounterClient() {
const { count, increment } = useCounterStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
Real-World Example: Shopping Cart
Let's implement a shopping cart using Zustand in Next.js:
- First, create the store:
// stores/useCartStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useCartStore = create(
persist(
(set, get) => ({
items: [],
totalItems: 0,
totalPrice: 0,
// Add item to cart
addItem: (item) => {
const { items } = get();
const existingItem = items.find((i) => i.id === item.id);
if (existingItem) {
// If item exists, increase quantity
const updatedItems = items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
);
set((state) => ({
items: updatedItems,
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + item.price,
}));
} else {
// If item doesn't exist, add it with quantity 1
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
totalItems: state.totalItems + 1,
totalPrice: state.totalPrice + item.price,
}));
}
},
// Remove item from cart
removeItem: (itemId) => {
const { items } = get();
const itemToRemove = items.find((i) => i.id === itemId);
if (!itemToRemove) return;
set((state) => ({
items: state.items.filter((i) => i.id !== itemId),
totalItems: state.totalItems - itemToRemove.quantity,
totalPrice: state.totalPrice - (itemToRemove.price * itemToRemove.quantity),
}));
},
// Update item quantity
updateItemQuantity: (itemId, quantity) => {
const { items } = get();
const item = items.find((i) => i.id === itemId);
if (!item) return;
const quantityDiff = quantity - item.quantity;
const updatedItems = items.map((i) =>
i.id === itemId ? { ...i, quantity } : i
);
set((state) => ({
items: updatedItems,
totalItems: state.totalItems + quantityDiff,
totalPrice: state.totalPrice + (item.price * quantityDiff),
}));
},
// Clear cart
clearCart: () => set({ items: [], totalItems: 0, totalPrice: 0 }),
}),
{
name: 'shopping-cart',
storage: typeof window !== 'undefined'
? {
getItem: (name) => {
const str = localStorage.getItem(name);
return str ? JSON.parse(str) : null;
},
setItem: (name, value) => {
localStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name) => localStorage.removeItem(name),
}
: undefined,
}
)
);
export default useCartStore;
- Now create a ProductCard component:
// components/ProductCard.jsx
'use client';
import useCartStore from '@/stores/useCartStore';
import Image from 'next/image';
export default function ProductCard({ product }) {
const { addItem } = useCartStore();
return (
<div className="product-card">
<Image
src={product.image}
alt={product.name}
width={200}
height={200}
/>
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
- Create a Cart component:
// components/Cart.jsx
'use client';
import useCartStore from '@/stores/useCartStore';
export default function Cart() {
const { items, totalItems, totalPrice, removeItem, updateItemQuantity, clearCart } = useCartStore();
if (items.length === 0) {
return <div className="cart">Your cart is empty</div>;
}
return (
<div className="cart">
<h2>Your Cart</h2>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<span>${item.price.toFixed(2)}</span>
<div className="quantity">
<button
onClick={() => updateItemQuantity(item.id, Math.max(1, item.quantity - 1))}
>
-
</button>
<span>{item.quantity}</span>
<button onClick={() => updateItemQuantity(item.id, item.quantity + 1)}>
+
</button>
</div>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
<div className="cart-summary">
<p>Total Items: {totalItems}</p>
<p>Total Price: ${totalPrice.toFixed(2)}</p>
<button onClick={clearCart}>Clear Cart</button>
</div>
</div>
);
}
- Use them in a page:
// app/shop/page.jsx
import ProductCard from '@/components/ProductCard';
import Cart from '@/components/Cart';
const products = [
{ id: 1, name: 'Product 1', price: 19.99, image: '/product1.jpg' },
{ id: 2, name: 'Product 2', price: 29.99, image: '/product2.jpg' },
{ id: 3, name: 'Product 3', price: 39.99, image: '/product3.jpg' },
];
export default function ShopPage() {
return (
<div className="shop-page">
<h1>Shop</h1>
<div className="layout">
<div className="products">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Cart />
</div>
</div>
);
}
Performance Optimization
Avoid Unnecessary Re-renders
Zustand automatically avoids unnecessary re-renders by allowing you to select only the state slices you need. However, you can further optimize performance:
// BAD: This will re-render when any part of the state changes
const { count, user, todos } = useStore();
// GOOD: Component only re-renders when these specific values change
const count = useStore(state => state.count);
const user = useStore(state => state.user);
Memoization for Complex Selectors
For complex derived state, use memoization:
import { useCallback, useMemo } from 'react';
import useStore from '@/stores/useStore';
function ExpensiveComponent() {
// Memoize the selector function
const selector = useCallback((state) => state.items.filter(item => item.important), []);
// Use the memoized selector
const importantItems = useStore(selector);
// Further process the result
const summary = useMemo(() => {
return importantItems.reduce((acc, item) => acc + item.value, 0);
}, [importantItems]);
return <div>Total: {summary}</div>;
}
Common Patterns with Zustand in Next.js
Async Actions
Zustand handles async actions elegantly:
const useDataStore = create((set) => ({
data: [],
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
}
}));
Combining Multiple Stores
While you can create separate stores for different domains, you might want to interact between them:
const useAuthStore = create((set) => ({
user: null,
// Auth actions...
}));
const useCartStore = create((set, get) => ({
items: [],
// Get user information from the auth store for the checkout process
checkout: async () => {
const user = useAuthStore.getState().user;
if (!user) return { success: false, error: 'Not logged in' };
// Process checkout with user info and cart items
// ...
}
}));
Summary
Zustand offers a refreshingly simple approach to state management in Next.js applications. In this tutorial, we've:
- Learned the basics of creating and using Zustand stores
- Explored advanced features like TypeScript integration, middleware, and persistence
- Built a practical shopping cart example
- Covered performance optimization techniques
- Examined common patterns for real-world applications
Zustand's minimal API and flexible design make it an excellent choice for Next.js projects of any size. Its ability to work seamlessly with Next.js 13+ App Router (with client components) ensures it remains relevant for modern Next.js development.
Additional Resources
To deepen your understanding of Zustand and Next.js state management:
Exercises
-
Build a todo list application using Zustand with features like adding, removing, filtering, and persisting todos.
-
Implement a theme switcher (light/dark mode) using Zustand that persists the user's preference.
-
Create a multi-step form with form state managed by Zustand.
-
Implement authentication state management with Zustand, handling login, logout, and protected routes in Next.js.
-
Build a real-time notification system using Zustand and WebSockets in Next.js.
By completing these exercises, you'll strengthen your understanding of state management with Zustand in Next.js applications and be well-equipped to tackle complex state management challenges in your projects.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)