Ever found yourself building a feature in a Next.js app and suddenly needing to track a simple piece of state—a user’s theme preference, a form’s draft status, a global notification—only to realize the “simple” solution becomes a tangled web of prop drilling or complex context providers? I hit this wall constantly. Each project presented the same dilemma: I wanted the power and SEO benefits of server-side rendering, but I also needed snappy, interactive client-side state that didn’t require a PhD in boilerplate to manage. My search for a middle ground is what led me to pair Zustand with Next.js, and the result transformed how I build applications.
You see, Next.js is a powerhouse for generating fast, optimized pages. But its hybrid nature—mixing server components, static generation, and client-side interactivity—can make state management feel messy. Context API is great for small apps, but pass data through many levels and you can trigger re-renders for the entire component tree. Redux offers structure but often feels like using a sledgehammer to crack a nut. The overhead just doesn’t make sense for a sidebar toggle or a shopping cart.
So, what if you could have a tiny, flexible store that lives right where you need it, without wrapping your app in layers of providers? That’s the core idea behind Zustand.
Let me show you how simple it is. First, you create a store. It’s just a function.
// store/useCartStore.js
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
addItem: (product) =>
set((state) => ({ items: [...state.items, product] })),
clearCart: () => set({ items: [] }),
}));
That’s it. No providers, no complex setup. To use this state in any client component, you just call the hook and pick the specific state or function you need. This selective usage is a game-changer; a component only re-renders when the piece of state it subscribes to actually changes.
// components/CartButton.jsx (Client Component)
'use client';
import useCartStore from '@/store/useCartStore';
export default function CartButton() {
// Only this component re-renders when `items` changes
const itemCount = useCartStore((state) => state.items.length);
const addItem = useCartStore((state) => state.addItem);
return (
<button onClick={() => addItem({ id: 1, name: 'New Item' })}>
Cart ({itemCount})
</button>
);
}
Clean, right? But here’s the first big question that likely popped into your head: How does this work with Next.js’s server rendering? Won’t the initial empty cart state “flash” on the screen after the page loads?
You’ve hit on the critical challenge: hydration. The server renders the page with the initial state (an empty cart). When JavaScript loads on the client, Zustand takes over. If we were to immediately try to show stored items from a previous session, we’d get a mismatch between the server HTML and the client HTML, causing an error. The solution is to only use Zustand stores after the component has “mounted” on the client.
This is a common pattern. You manage the hydration by initializing the store only in the client’s lifecycle.
// store/useThemeStore.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // Handles localStorage
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{
name: 'theme-storage', // unique name for localStorage key
}
)
);
// components/ThemeToggle.jsx
'use client';
import { useEffect, useState } from 'react';
import useThemeStore from '@/store/useThemeStore';
export default function ThemeToggle() {
// State to track client-side mount
const [isMounted, setIsMounted] = useState(false);
const { theme, toggleTheme } = useThemeStore();
useEffect(() => {
setIsMounted(true);
}, []);
// Avoid rendering anything that depends on client state until mounted
if (!isMounted) {
return <div className="h-6 w-12"></div>; // Return a placeholder
}
return <button onClick={toggleTheme}>Current theme: {theme}</button>;
}
By using persist middleware, the theme preference survives page refreshes, and by checking for mount, we prevent the hydration mismatch. This pattern is your key to integrating any persistent client state smoothly.
What about when your state isn’t just a simple value? What if you have complex logic or async actions, like fetching user data? Zustand doesn’t get in your way. You can write your stores to include asynchronous actions cleanly.
// store/useUserStore.js
import { create } from 'zustand';
const useUserStore = create((set) => ({
userData: null,
isLoading: false,
error: null,
fetchUser: async (userId) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
set({ userData: data, isLoading: false });
} catch (err) {
set({ error: err.message, isLoading: false });
}
},
}));
Now, any component can trigger the fetch, and the entire UI can react to the isLoading and userData states. It keeps your component logic thin and your state logic centralized.
The beauty of this setup is its scalability. You can create multiple, independent stores for different domains of your application—one for UI themes, one for a shopping cart, one for user profile. They stay small, focused, and easy to reason about. When a feature grows in complexity, its related store can grow with it, without forcing you to refactor unrelated parts of your app.
I encourage you to try it. Start by replacing one React Context in your Next.js project with a small Zustand store. Feel the difference in simplicity and watch the unnecessary re-renders disappear. It’s the lightweight, powerful companion Next.js client components always needed.
Has this approach clarified the path for your own project’s state management? I’d love to hear about your experiences or answer any questions you have. If this breakdown was helpful, please consider liking or sharing it with other developers who might be facing the same architectural decisions. Let me know your thoughts in the comments below
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva