Zustand + TypeScript: Type-Safe State Management Done Right

  • Home
  • Zustand
  • Zustand + TypeScript: Type-Safe State Management Done Right
Front
Back
Right
Left
Top
Bottom
WHY

Why TypeScript + Zustand is a Perfect Match

TypeScript transformed JavaScript development, and when combined with Zustand, you get bulletproof state management. As Shantanu Pokale notes, Zustand integrates smoothly with TypeScript, offering great static typing support with minimal boilerplate.

This blog covers typing patterns that catch bugs before they happen.
START-UP
The Curried Pattern

Basic TypeScript Setup

The key difference in TypeScript is using the curried form: `create<State>()((set) => …)` instead of just `create((set) => …)`.

JavaScript Way (No Safety)

⚛️
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}))
The Zustand documentation explains why TypeScript can’t infer types automatically: the state generic `T` is invariant (both covariant and contravariant), making automatic inference impossible.

TypeScript Way (Type Safe)

📘
import { create } from 'zustand'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))
#01
Pattern 1

Separating State and Actions

Professional TypeScript developers separate concerns using interfaces:
📘
interface BearState {
  bears: number
  fish: number
}

interface BearActions {
  increasePopulation: () => void
  removeAllBears: () => void
  updateBears: (bears: number) => void
}

type BearStore = BearState & BearActions

const useBearStore = create<BearStore>()((set) => ({
  // State
  bears: 0,
  fish: 0,
  
  // Actions
  increasePopulation: () => set((state) => ({ 
    bears: state.bears + 1 
  })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (bears) => set({ bears }),
}))
Benefits
#02
Pattern 2

Typing Selectors

Selectors automatically infer types from the store:
📘
function BearCounter() {
  // Type of 'bears' is inferred as number
  const bears = useBearStore((state) => state.bears)
  
  // Type of 'increasePopulation' is inferred as () => void
  const increasePopulation = useBearStore((state) => state.increasePopulation)
  
  return (
    <div>
      <h1>{bears} bears around here</h1>
      <button onClick={increasePopulation}>Add bear</button>
    </div>
  )
}

Using useShallow for Multiple Selections

When selecting multiple values, use `useShallow` to prevent re-renders:
📘
import { useShallow } from 'zustand/react/shallow'

function BearInfo() {
  const { bears, fish } = useBearStore(
    useShallow((state) => ({ 
      bears: state.bears, 
      fish: state.fish 
    }))
  )
  
  return <div>Bears: {bears}, Fish: {fish}</div>
}
The Zustand TypeScript guide recommends wrapping selectors with `useShallow` to prevent re-renders when selected values remain shallowly equal.
#03
Pattern 3

Typing with Middleware

Middleware requires proper TypeScript setup to avoid type errors:
📘
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type {} from '@redux-devtools/extension' // Required for devtools typing

interface AuthState {
  user: User | null
  token: string | null
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const useAuthStore = create<AuthState>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        token: null,
        isAuthenticated: false,
        
        login: async (email, password) => {
          const response = await authAPI.login(email, password)
          set({ 
            user: response.user, 
            token: response.token,
            isAuthenticated: true 
          })
        },
        
        logout: () => set({ 
          user: null, 
          token: null, 
          isAuthenticated: false 
        }),
      }),
      { name: 'auth-storage' }
    ),
    { name: 'AuthStore' }
  )
)
#04
Pattern 4

Typing Slices

When using slices, type each slice separately:
📘
// userSlice.ts
interface UserSliceState {
  user: User | null
  isLoading: boolean
}

interface UserSliceActions {
  setUser: (user: User) => void
  clearUser: () => void
}

type UserSlice = UserSliceState & UserSliceActions

export const createUserSlice = (set: StateCreator<UserSlice>): UserSlice => ({
  user: null,
  isLoading: false,
  
  setUser: (user) => set({ user, isLoading: false }),
  clearUser: () => set({ user: null }),
})

// cartSlice.ts
interface CartSliceState {
  items: CartItem[]
  total: number
}

interface CartSliceActions {
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
}

type CartSlice = CartSliceState & CartSliceActions

export const createCartSlice = (set: StateCreator<CartSlice>): CartSlice => ({
  items: [],
  total: 0,
  
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
    total: state.total + item.price,
  })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),
})

// Combine slices
import { create } from 'zustand'

type AppStore = UserSlice & CartSlice

const useAppStore = create<AppStore>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}))
#05
Pattern 5

Resetting State with Type Safety

Use `typeof initialState` to avoid repeating types:
📘
interface TodoState {
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  resetState: () => void
}

const initialState = {
  todos: [] as Todo[],
  filter: 'all' as const,
}

const useTodoStore = create<TodoState>()((set) => ({
  ...initialState,
  
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: uuid(), text, completed: false }],
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ),
  })),
  
  resetState: () => set(initialState), // TypeScript ensures compatibility
}))

Common TypeScript Patterns

Using useShallow for Multiple Selections

📘
interface BaseState<T> {
  data: T | null
  isLoading: boolean
  error: string | null
  fetch: () => Promise<void>
  reset: () => void
}

function createDataStore<T>(fetchFn: () => Promise<T>) {
  return create<BaseState<T>>()((set) => ({
    data: null,
    isLoading: false,
    error: null,
    
    fetch: async () => {
      set({ isLoading: true, error: null })
      try {
        const data = await fetchFn()
        set({ data, isLoading: false })
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },
    
    reset: () => set({ data: null, isLoading: false, error: null }),
  }))
}

// Usage
const useUserStore = createDataStore<User>(() => api.fetchUser())
const usePostsStore = createDataStore<Post[]>(() => api.fetchPosts())

Explore project snapshots or discuss custom web solutions.

ADVANCED

Strict Mode Configuration

Enable strict TypeScript checks in `tsconfig.json`:
📋
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitAny": true
  }
}
TypeScript best practices in 2026 emphasize strict mode as the default configuration for type safety.
TIPS

Type Inference Tips

Type systems are about catching bugs early, not annoying developers.

Anders Hejlsberg, Lead Architect of TypeScript

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

The curried form is required for proper TypeScript inference. It's a workaround for TypeScript's limitation with invariant generics. The Zustand docs explain this is due to the impossibility of implementing type-perfect inference.

Same as sync actions, just return `Promise<void>`: `login: (email: string) => Promise<void>`. TypeScript infers the promise automatically.

Zustand hooks work only in Client Components. Mark components with `'use client'` directive. The Zustand README warns against adding state in React Server Components to avoid bugs and privacy issues.

Either works. Interfaces are more extensible, types are more powerful. Pick one and stay consistent. Modern TS best practices slightly favor interfaces for object shapes.

Use discriminated unions: `type State = {status: 'loading'} | {status: 'success', data: T} | {status: 'error', error: string}`. TypeScript narrows types based on status.

Comments are closed