The web app uses Zustand for state management when needed. Zustand is lightweight, has no boilerplate, and works seamlessly with React Server Components.
Why Zustand?
Minimal Boilerplate No providers, actions, or reducers required
TypeScript First Full type inference out of the box
React 19 Compatible Works with Server and Client Components
DevTools Support Built-in Redux DevTools integration
When to Use State Management
Use Zustand For
Don't Use For
✅ Global state shared across multiple components ✅ Complex state logic with multiple actions ✅ Persistent state that needs to survive remounts ✅ Derived state with computed values ✅ State that changes frequently (real-time updates)
❌ Local component state - use useState instead ❌ Server data - use Next.js Server Components or React Query ❌ URL state - use Next.js searchParams or useRouter ❌ Form state - use React Hook Form or native form state
Installation
Zustand is not included by default. Add it when you need it:
Basic Store
Create a store with actions:
features/counter/counter-store.ts
import { create } from 'zustand' ;
interface CounterStore {
count : number ;
increment : () => void ;
decrement : () => void ;
reset : () => void ;
}
export const useCounterStore = create < CounterStore >(( set ) => ({
count: 0 ,
increment : () => set (( state ) => ({ count: state . count + 1 })),
decrement : () => set (( state ) => ({ count: state . count - 1 })),
reset : () => set ({ count: 0 }),
}));
Using the Store
features/counter/counter-page.tsx
"use client" ;
import { useCounterStore } from './counter-store' ;
import { Button } from '@/components/ui/button' ;
export function CounterPage () {
const count = useCounterStore (( state ) => state . count );
const increment = useCounterStore (( state ) => state . increment );
const decrement = useCounterStore (( state ) => state . decrement );
return (
< div >
< p > Count: { count } </ p >
< Button onClick = { increment } > + </ Button >
< Button onClick = { decrement } > - </ Button >
</ div >
);
}
Use selector functions (state) => state.count to subscribe only to the state you need. This prevents unnecessary re-renders.
Advanced Patterns
Async Actions
Handle async operations in actions:
features/posts/posts-store.ts
import { create } from 'zustand' ;
import { getPosts } from './posts-api' ;
interface PostsStore {
posts : Post [];
loading : boolean ;
error : string | null ;
fetchPosts : () => Promise < void >;
}
export const usePostsStore = create < PostsStore >(( set ) => ({
posts: [],
loading: false ,
error: null ,
fetchPosts : async () => {
set ({ loading: true , error: null });
try {
const posts = await getPosts ();
set ({ posts , loading: false });
} catch ( error ) {
set ({ error: error . message , loading: false });
}
},
}));
Computed Values (Selectors)
Create derived state with selectors:
features/todos/todos-store.ts
import { create } from 'zustand' ;
interface TodosStore {
todos : Todo [];
addTodo : ( text : string ) => void ;
toggleTodo : ( id : string ) => void ;
}
export const useTodosStore = create < TodosStore >(( set ) => ({
todos: [],
addTodo : ( text ) => set (( state ) => ({
todos: [ ... state . todos , { id: crypto . randomUUID (), text , completed: false }]
})),
toggleTodo : ( id ) => set (( state ) => ({
todos: state . todos . map ( todo =>
todo . id === id ? { ... todo , completed: ! todo . completed } : todo
)
})),
}));
// Computed selectors
export const useCompletedTodos = () =>
useTodosStore (( state ) => state . todos . filter ( t => t . completed ));
export const useActiveTodos = () =>
useTodosStore (( state ) => state . todos . filter ( t => ! t . completed ));
export const useTodoStats = () =>
useTodosStore (( state ) => ({
total: state . todos . length ,
completed: state . todos . filter ( t => t . completed ). length ,
active: state . todos . filter ( t => ! t . completed ). length ,
}));
Usage:
const completedTodos = useCompletedTodos ();
const { total , completed , active } = useTodoStats ();
Middleware - Persistence
Persist state to localStorage:
features/settings/settings-store.ts
import { create } from 'zustand' ;
import { persist } from 'zustand/middleware' ;
interface SettingsStore {
theme : 'light' | 'dark' | 'system' ;
notifications : boolean ;
setTheme : ( theme : 'light' | 'dark' | 'system' ) => void ;
toggleNotifications : () => void ;
}
export const useSettingsStore = create < SettingsStore >()(
persist (
( set ) => ({
theme: 'system' ,
notifications: true ,
setTheme : ( theme ) => set ({ theme }),
toggleNotifications : () => set (( state ) => ({
notifications: ! state . notifications
})),
}),
{
name: 'settings-storage' , // localStorage key
}
)
);
Persisted stores can cause hydration mismatches in SSR. Wrap components using persisted stores in a client boundary.
Enable Redux DevTools for debugging:
features/app/app-store.ts
import { create } from 'zustand' ;
import { devtools } from 'zustand/middleware' ;
interface AppStore {
user : User | null ;
setUser : ( user : User ) => void ;
}
export const useAppStore = create < AppStore >()((
devtools (
( set ) => ({
user: null ,
setUser : ( user ) => set ({ user }, false , 'setUser' ),
}),
{ name: 'AppStore' }
)
));
Slices Pattern
Split large stores into slices:
features/app/app-store.ts
import { create } from 'zustand' ;
// User slice
interface UserSlice {
user : User | null ;
setUser : ( user : User ) => void ;
clearUser : () => void ;
}
const createUserSlice = ( set ) : UserSlice => ({
user: null ,
setUser : ( user ) => set ({ user }),
clearUser : () => set ({ user: null }),
});
// Settings slice
interface SettingsSlice {
theme : 'light' | 'dark' ;
setTheme : ( theme : 'light' | 'dark' ) => void ;
}
const createSettingsSlice = ( set ) : SettingsSlice => ({
theme: 'light' ,
setTheme : ( theme ) => set ({ theme }),
});
// Combined store
type AppStore = UserSlice & SettingsSlice ;
export const useAppStore = create < AppStore >(( set ) => ({
... createUserSlice ( set ),
... createSettingsSlice ( set ),
}));
Best Practices
Selector Functions Always use selectors to avoid unnecessary re-renders // ✅ Good
const count = useStore ( s => s . count );
// ❌ Bad - re-renders on any change
const { count } = useStore ();
Co-locate Stores Keep stores with their features features/todos/
├── todos-page.tsx
└── todos-store.ts ← Co-located
Type Safety Always define TypeScript interfaces interface Store {
count : number ;
increment : () => void ;
}
create < Store >(( set ) => ... )
Immutable Updates Use immutable patterns in set() // ✅ Good
set ( state => ({
items: [ ... state . items , newItem ]
}))
// ❌ Bad - mutates state
set ( state => {
state . items . push ( newItem );
return state ;
})
Shallow Comparison
Use shallow for selecting multiple values:
import { shallow } from 'zustand/shallow' ;
const { count , increment , decrement } = useCounterStore (
( state ) => ({
count: state . count ,
increment: state . increment ,
decrement: state . decrement
}),
shallow
);
Separate Stores
Split frequently-changing and rarely-changing state:
// Fast-changing data
export const useRealtimeStore = create (( set ) => ({
messages: [],
addMessage : ( msg ) => set (( s ) => ({ messages: [ ... s . messages , msg ] })),
}));
// Slow-changing config
export const useConfigStore = create (( set ) => ({
apiUrl: '' ,
setApiUrl : ( url ) => set ({ apiUrl: url }),
}));
Testing Stores
Test stores independently:
tests/counter-store.test.ts
import { renderHook , act } from '@testing-library/react' ;
import { useCounterStore } from '@/features/counter/counter-store' ;
describe ( 'CounterStore' , () => {
beforeEach (() => {
useCounterStore . setState ({ count: 0 });
});
it ( 'increments count' , () => {
const { result } = renderHook (() => useCounterStore ());
act (() => {
result . current . increment ();
});
expect ( result . current . count ). toBe ( 1 );
});
});
Migration from Redux
// Actions
const INCREMENT = 'INCREMENT' ;
const increment = () => ({ type: INCREMENT });
// Reducer
const reducer = ( state = { count: 0 }, action ) => {
switch ( action . type ) {
case INCREMENT :
return { count: state . count + 1 };
default :
return state ;
}
};
// Store
const store = createStore ( reducer );
// Provider
< Provider store = { store } >
< App />
</ Provider >
// Usage
const count = useSelector ( state => state . count );
const dispatch = useDispatch ();
dispatch ( increment ());
// Store (combines actions + state)
const useCounterStore = create (( set ) => ({
count: 0 ,
increment : () => set (( s ) => ({ count: s . count + 1 })),
}));
// No provider needed!
// Usage
const count = useCounterStore ( s => s . count );
const increment = useCounterStore ( s => s . increment );
increment ();
Benefits:
90% less boilerplate
No providers
No action creators
No reducers
Direct function calls
Next Steps
Features Build features with integrated state
Testing Test components with Zustand stores
Zustand Docs Official Zustand documentation