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(): T
  • setState(partial: Partial<T> | (state: T) => Partial<T>): void
  • subscribe(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, set knows state is CounterState, so state.count is typed as number.
  • The hook useCounterStore returns a selector whose argument is inferred as CounterState, 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.ts defines StoreApi<T> and StateCreator<T> as fully generic, ensuring every store method is typed to your state interface.
  • React integration: src/react.ts exports create<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 same StateCreator generics.
  • Vanilla support: createStore<State> from zustand/vanilla provides 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →