7 min read

Type‑Safe State Management with Zustand and TypeScript in Next.js

Learn how to build a fully typed Zustand store for a Next.js app, covering slices, async actions, SSR, and testing—all in plain TypeScript.
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:

  1. Define a typed store using slices that keep concerns separated.
  2. Leverage TypeScript generics so every selector and action is type‑checked.
  3. Handle async logic (e.g., fetching data) without losing type safety.
  4. Make the store work with Next.js rendering modes (SSR, SSG, and CSR).
  5. 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 StateCreator generic 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)
  }))
)
  • devtools middleware is optional but useful during development.
  • The final useStore hook is fully typed: every selector you write will be checked against AppStore.

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 fetchUser returns a Promise<void>, TypeScript warns us if we forget to await it 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.setState is 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 to UserState, 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 set calls made within the same tick, but you can also manually batch with set(state => { … }) to avoid extra renders.
  • Persisted state: For client‑side persistence, use the persist middleware. 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 useStore hook 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

  1. Add middleware for logging (zustand/middlewaresubscribeWithSelector).
  2. Explore selector memoization with useStoreApi and useCallback.
  3. 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!