
Setting up Zustand in your React App
"A global store with hooks, without Providers or reducers. Zustand scales from a single counter to app-wide state with minimal boilerplate."
Zustand is a small, fast state-management library for React. In this post I'll build a typed store from scratch, use it efficiently with selectors, then layer in persist and devtools middleware.
Install
Add Zustand to your React or Next.js project.
npm i zustand
# or
yarn add zustand
# or
pnpm add zustandCreate a store
Define state and actions, then pass them to create. No reducers, no action types, no Provider required.
import { create } from "zustand";
type CounterState = {
count: number;
inc: () => void;
dec: () => void;
setCount: (next: number) => void;
reset: () => void;
};
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
dec: () => set((s) => ({ count: s.count - 1 })),
setCount: (next) => set({ count: next }),
reset: () => set({ count: 0 }),
}));Zustand stores are module-level singletons by default. No wrapping your app in a Provider.
Use the store in a component
Call the hook and pick only what you need. Selectors ensure the component only re-renders when that specific slice changes.
import React from "react";
import { useCounterStore } from "../store/useCounterStore";
export function Counter() {
const count = useCounterStore((s) => s.count);
const inc = useCounterStore((s) => s.inc);
const dec = useCounterStore((s) => s.dec);
const reset = useCounterStore((s) => s.reset);
return (
<div className="flex items-center gap-3">
<button onClick={dec}>-</button>
<span>{count}</span>
<button onClick={inc}>+</button>
<button onClick={reset}>reset</button>
</div>
);
}Tip: avoid calling useCounterStore() without a selector on large stores. It subscribes the component to the entire store and can cause unnecessary re-renders.
Add persistence and DevTools
Zustand ships middleware out of the box. persist saves state to localStorage. devtools connects to the Redux DevTools extension for time-travel debugging.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type CounterState = {
count: number;
inc: () => void;
dec: () => void;
reset: () => void;
};
export const useCounterStore = create<CounterState>()(
devtools(
persist(
(set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 }), false, "counter/inc"),
dec: () => set((s) => ({ count: s.count - 1 }), false, "counter/dec"),
reset: () => set({ count: 0 }, false, "counter/reset"),
}),
{ name: "counter-store" }
),
{ name: "CounterStore" }
)
);The third argument to set is an action name for DevTools. Named actions make debugging significantly easier.
Common pitfalls
persist uses localStorage, which doesn't exist during SSR. It's fine as long as the store is only read in the browser. If you render store state on the server, you may see hydration mismatches.(s) => s.count) so React only re-renders when that slice changes. Avoid calling the hook without a selector on large stores.State without the ceremony.
Zustand's sweet spot is how little ceremony it needs. Start with a tiny store, use selectors for performance, and add middleware as your app grows.
No boilerplate. No Provider. Just a hook and a store.