Type‑Safe State Management with Zustand and TypeScript in Next.js
Introduction
State management in React often feels like a trade‑off between simplicity and type safety. Redux gives you a solid type system but brings a lot of boilerplate; the Context API is lightweight but can become unwieldy as the app grows. Zustand sits in the middle: a tiny, hook‑based store with a minimal API, yet it plays nicely with TypeScript’s inference capabilities.
In this article we’ll walk through a real‑world Next.js project and show how to:
- Define a typed store using slices that keep concerns separated.
- Leverage TypeScript generics so every selector and action is type‑checked.
- Handle async logic (e.g., fetching data) without losing type safety.
- Make the store work with Next.js rendering modes (SSR, SSG, and CSR).
- Test the store in isolation with Jest.
No external validation libraries, no heavy reducers—just pure TypeScript and Zustand.
1. Setting Up the Project
npx create-next-app@latest my-zustand-app --typescript
cd my-zustand-app
npm i zustand
Zustand ships with its own TypeScript definitions, so you get full IntelliSense out of the box.
2. Designing the Store Shape
A good practice is to split the global state into slices—small, self‑contained modules that each own a piece of state and the actions that mutate it.
// src/store/userSlice.ts
import { StateCreator } from 'zustand'
export interface User {
id: string
name: string
email: string
}
export interface UserState {
user: User | null
loading: boolean
error: string | null
// actions
fetchUser: (id: string) => Promise<void>
logout: () => void
}
/** Slice creator – receives the full store type for cross‑slice access */
export const createUserSlice: StateCreator<
UserState & any, // `any` will be replaced by the full store later
[],
[],
UserState
> = (set, get) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Network error')
const data: User = await res.json()
set({ user: data, loading: false })
} catch (e) {
set({ error: (e as Error).message, loading: false })
}
},
logout: () => set({ user: null })
})
Key points:
- Explicit interfaces (
User,UserState) give us a contract that never drifts. - The slice returns an object that matches the interface exactly, so any typo is caught at compile time.
- The
StateCreatorgeneric lets us later merge slices without losing type information.
3. Combining Slices into a Global Store
// src/store/index.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createUserSlice, UserState } from './userSlice'
import { createCartSlice, CartState } from './cartSlice' // we’ll add this later
export type AppStore = UserState & CartState
export const useStore = create<AppStore>()(
devtools((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get)
}))
)
devtoolsmiddleware is optional but useful during development.- The final
useStorehook is fully typed: every selector you write will be checked againstAppStore.
4. Adding a Second Slice – Shopping Cart
// src/store/cartSlice.ts
import { StateCreator } from 'zustand'
export interface CartItem {
productId: string
quantity: number
price: number
}
export interface CartState {
items: CartItem[]
total: number
addItem: (item: Omit<CartItem, 'price'>, price: number) => void
removeItem: (productId: string) => void
clear: () => void
}
export const createCartSlice: StateCreator<
CartState & any,
[],
[],
CartState
> = (set, get) => ({
items: [],
total: 0,
addItem: (item, price) => {
const existing = get().items.find(i => i.productId === item.productId)
const newItems = existing
? get().items.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
)
: [...get().items, { ...item, price }]
const newTotal = newItems.reduce((sum, i) => sum + i.price * i.quantity, 0)
set({ items: newItems, total: newTotal })
},
removeItem: (productId) => {
const newItems = get().items.filter(i => i.productId !== productId)
const newTotal = newItems.reduce((sum, i) => sum + i.price * i.quantity, 0)
set({ items: newItems, total: newTotal })
},
clear: () => set({ items: [], total: 0 })
})
Notice the use of Omit<CartItem, 'price'> for the addItem action. The price is supplied separately, which mirrors many real APIs where the client receives pricing from a separate service.
5. Using the Store in Components
// src/components/Profile.tsx
import { useStore } from '@/store'
export default function Profile({ userId }: { userId: string }) {
const { user, loading, error, fetchUser, logout } = useStore(
state => ({
user: state.user,
loading: state.loading,
error: state.error,
fetchUser: state.fetchUser,
logout: state.logout
})
)
React.useEffect(() => {
fetchUser(userId)
}, [userId, fetchUser])
if (loading) return <p>Loading…</p>
if (error) return <p>Error: {error}</p>
if (!user) return null
return (
<section>
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={logout}>Log out</button>
</section>
)
}
- The selector function (
state => ({ … })) narrows the type to only the fields we need, preventing accidental re‑renders caused by unrelated state changes. - Because
fetchUserreturns aPromise<void>, TypeScript warns us if we forget toawaitit inside an async context.
6. Server‑Side Rendering (SSR) Considerations
Next.js can render pages on the server, on the edge, or statically. Zustand stores are client‑only by default, but you can hydrate them with data fetched on the server.
// src/pages/profile/[id].tsx
import { GetServerSideProps } from 'next'
import Profile from '@/components/Profile'
import { useStore } from '@/store'
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const id = params?.id as string
// Pre‑fetch user data on the server
const res = await fetch(`https://api.example.com/users/${id}`)
const user = await res.json()
return { props: { preloadedUser: user, id } }
}
type Props = {
preloadedUser: {
id: string
name: string
email: string
}
id: string
}
export default function Page({ preloadedUser, id }: Props) {
// Hydrate the store on the client side only
const setUser = useStore(state => state.user)
React.useEffect(() => {
// Directly set the user without triggering a fetch
useStore.setState({ user: preloadedUser, loading: false })
}, [preloadedUser])
return <Profile userId={id} />
}
useStore.setStateis a static method that lets us write to the store outside of a component.- Because the server never runs the React code, the store remains empty during SSR, avoiding memory leaks.
If you need the store on the server (e.g., for API routes), instantiate a new store per request:
// src/lib/createServerStore.ts
import { create } from 'zustand'
import { createUserSlice } from '@/store/userSlice'
import { createCartSlice } from '@/store/cartSlice'
export const createServerStore = () =>
create()((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get)
}))
Pass this store to your handlers, and you keep type safety across the boundary.
7. Async Actions with TypeScript – A Deeper Look
Zustand’s actions can be async, but you lose the ability to type the pending state automatically. A small helper can bridge that gap:
// src/store/helpers.ts
export function withLoading<T extends (...args: any[]) => Promise<any>>(
fn: T,
setLoading: (loading: boolean) => void
) {
return async (...args: Parameters<T>) => {
setLoading(true)
try {
await fn(...args)
} finally {
setLoading(false)
}
}
}
Usage inside a slice:
// inside createUserSlice
fetchUser: withLoading(
async (id: string) => {
const res = await fetch(`/api/users/${id}`)
const data: User = await res.json()
set({ user: data })
},
(loading) => set({ loading })
)
Now the loading flag is guaranteed to be toggled correctly, and any mistake (e.g., forgetting to set it back) is caught by the compiler because withLoading forces you to provide a setter that matches the store shape.
8. Testing the Store
Because Zustand stores are plain objects, you can test them without rendering React.
// __tests__/userSlice.test.ts
import { create } from 'zustand'
import { createUserSlice, UserState } from '@/store/userSlice'
type TestStore = UserState
const makeStore = () => create<TestStore>()((set, get) => ({
...createUserSlice(set, get)
}))
test('fetchUser populates user on success', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Alice', email: 'alice@example.com' })
})
const store = makeStore()
await store.getState().fetchUser('1')
expect(store.getState().user).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com'
})
expect(store.getState().loading).toBe(false)
expect(store.getState().error).toBeNull()
})
- The test imports only the slice, not any React component.
- TypeScript guarantees that
store.getState()conforms toUserState, so a typo in the test (e.g.,store.getState().userr) would fail at compile time.
9. Common Pitfalls & How to Avoid Them
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Mutating state directly | Zustand expects immutable updates; mutating an object in place can lead to stale selectors. | Always return a new object (set(state => ({ …state, foo: newValue }))). |
| Using the same store instance on the server | A global store would be shared across requests, leaking data. | Create a new store per request (see createServerStore). |
| Selector over‑fetching | Selecting the whole store forces every component to re‑render on any change. | Use fine‑grained selectors (state => state.user) or shallow from zustand/shallow. |
| Forgotten async error handling | Unhandled promise rejections crash the app. | Wrap async actions in try/catch and expose an error field in the slice. |
| Type drift between slices | Adding a new field to a slice but forgetting to export it from the global type. | Keep a single source of truth (AppStore = UserState & CartState). The compiler will alert you if a slice is missing. |
10. Performance Tips
- Batch updates: Zustand batches multiple
setcalls made within the same tick, but you can also manually batch withset(state => { … })to avoid extra renders. - Persisted state: For client‑side persistence, use the
persistmiddleware. It works with TypeScript out of the box:
import { persist } from 'zustand/middleware'
export const useStore = create<AppStore>()(
persist(
(set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get)
}),
{ name: 'my-app-storage' }
)
)
- Selective subscription: The
useStorehook accepts a second argument for equality checking (shallow). This prevents re‑renders when unrelated parts of the state change.
11. Recap
- Slices keep the store modular and type‑safe.
- TypeScript generics (
StateCreator) preserve the contract across slices. - Async actions can be wrapped to guarantee loading state handling.
- SSR requires explicit hydration; never share a global store between requests.
- Testing is straightforward because the store is just a plain object.
With these patterns, Zustand becomes a first‑class citizen in a TypeScript‑heavy Next.js codebase, delivering the ergonomics of a tiny hook library while still giving you the compile‑time guarantees you expect from a large‑scale application.
12. Next Steps
- Add middleware for logging (
zustand/middleware→subscribeWithSelector). - Explore selector memoization with
useStoreApianduseCallback. - Integrate with React Server Components by passing the pre‑loaded state as props.
Happy coding, and enjoy the simplicity of a truly type‑safe state layer!
Member discussion