How to Use Zustand with TypeScript for Type‑Safe State Management
Zustand achieves type‑safe state management by exposing fully generic core types in src/vanilla.ts and src/react.ts that infer your state shape through the create<State>() factory, ensuring compile‑time safety for actions, selectors, and middleware without requiring decorators or interfaces.
Zustand is a minimal, unopinionated state management library maintained in the pmndrs/zustand repository. Its TypeScript implementation leverages generic type parameters that flow from the vanilla store core through React hooks, allowing you to define your state shape once and have it propagate to every set, get, and subscription call.
Core Type Architecture in src/vanilla.ts
The foundation of Zustand’s type system lives in src/vanilla.ts, which exports the generic StoreApi<T> interface and the StateCreator<T, Mis, Mos, U> type. These definitions ensure that every store instance knows the exact shape of its state at compile time.
The StoreApi<T> interface types the three core methods:
getState(): TsetState(partial: Partial<T> | (state: T) => Partial<T>): voidsubscribe(listener: (state: T, prevState: T) => void): () => void
Because T is generic, calling store.getState() returns your specific state type rather than any or unknown.
The StateCreator Signature
When you define a store, you pass a state creator function to the factory. As implemented in src/vanilla.ts, this function receives strongly typed set and get arguments derived from StoreApi<T>:
type StateCreator<T, Mis, Mos, U = T> = (
setState: (fn: (state: T) => Partial<T>) => void,
getState: () => T,
store: StoreApi<T>
) => U
This means your actions automatically know the state type they read and return, preventing you from accidentally setting non‑existent properties.
Creating a Type‑Safe React Store
For React projects, src/react.ts exports the create factory. You lock the store to your interface by passing the type parameter before the function call: create<YourState>()(…).
import { create } from 'zustand'
interface CounterState {
count: number
inc: () => void
dec: (by: number) => void
}
export const useCounterStore = create<CounterState>()(set => ({
count: 0,
inc: () => set(state => ({ count: state.count + 1 })),
dec: (by) => set(state => ({ count: state.count - by })),
}))
Key points:
- The generic
<CounterState>fixes the type for the entire store lifecycle. - Inside the creator,
setknowsstateisCounterState, sostate.countis typed asnumber. - The hook
useCounterStorereturns a selector whose argument is inferred asCounterState, giving you autocomplete and compile‑time checking.
Usage in a component preserves these guarantees:
function Counter() {
const { count, inc, dec } = useCounterStore(state => ({
count: state.count,
inc: state.inc,
dec: state.dec,
}))
return (
<div>
<p>{count}</p>
<button onClick={inc}>+</button>
<button onClick={() => dec(2)}>-2</button>
</div>
)
}
Accessing state.nonExistentProperty here would raise an immediate TypeScript error because the selector’s parameter is bound to CounterState.
Composing Middleware with Type Preservation
Zustand middleware such as persist, devtools, and immer are implemented as higher‑order functions that wrap your StateCreator. Because they accept and return the same generic StateCreator<T, …> signature, your type constraints flow through every layer.
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
interface Todo {
id: string
text: string
done: boolean
}
interface TodoState {
todos: Todo[]
add: (t: Todo) => void
toggle: (id: string) => void
}
export const useTodoStore = create<TodoState>()(
devtools(
persist(
set => ({
todos: [],
add: (t) => set(state => ({ todos: [...state.todos, t] })),
toggle: (id) =>
set(state => ({
todos: state.todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
),
})),
}),
{ name: 'todo-storage' }
)
)
)
Even after wrapping the creator in devtools and persist, the resulting hook still knows that todos is an array of Todo objects and that add expects a Todo argument.
Type‑Safe Vanilla Stores Outside React
Zustand’s vanilla API, also defined in src/vanilla.ts, allows you to use the same type system in non‑React environments. Import createStore from zustand/vanilla to instantiate a store that works in Node.js, testing utilities, or any JavaScript runtime.
import { createStore } from 'zustand/vanilla'
interface AuthState {
user: string | null
login: (name: string) => void
logout: () => void
}
export const authStore = createStore<AuthState>(set => ({
user: null,
login: name => set({ user: name }),
logout: () => set({ user: null }),
}))
// Direct usage outside React
authStore.getState().login('alice')
console.log(authStore.getState().user) // "alice"
Because createStore is generic over AuthState, the getState() method returns { user: string | null; login: …; logout: … }, and set only accepts partial updates matching that interface.
Summary
- Generic core:
src/vanilla.tsdefinesStoreApi<T>andStateCreator<T>as fully generic, ensuring every store method is typed to your state interface. - React integration:
src/react.tsexportscreate<State>()which binds the generic parameter to the hook and selector functions. - Middleware flow: Middleware in
src/middleware/*preserve type safety by accepting and returning the sameStateCreatorgenerics. - Vanilla support:
createStore<State>fromzustand/vanillaprovides type‑safe state management outside React with identical inference.
Frequently Asked Questions
How do I type the store state in Zustand?
Declare your state shape as a TypeScript interface or type, then pass it as the generic argument to create<YourState>(). This locks the set and get functions to that shape, giving you autocomplete and compile‑time errors for invalid property access.
Does Zustand middleware break TypeScript inference?
No. Zustand middleware are designed as higher‑order functions that accept and return a StateCreator with the same generic parameters. Whether you use persist, devtools, or immer, the original state type flows through the composition unchanged.
Can I use Zustand types without React?
Yes. Import createStore from zustand/vanilla to instantiate a store using the same generic types found in src/vanilla.ts. This gives you typed getState(), setState(), and subscribe() methods for use in any JavaScript environment.
How do I type store selectors for better performance?
When calling the hook returned by create, pass a selector function that receives your state type as its argument. TypeScript infers the parameter from the store’s generic, ensuring the selector is type‑safe. For strict equality checks, you can also import useShallow from zustand/react/shallow to prevent unnecessary re‑renders while maintaining type inference.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →