← Back to DevBytes

Zustand TypeScript: Strongly Typed Applications

Introduction to Zustand with TypeScript

Zustand is a lightweight, fast, and scalable state management library for React. When paired with TypeScript, it becomes a powerful tool for building strongly typed applications where state shape, actions, and selectors are all verified at compile time. This combination eliminates an entire class of runtime errors and provides excellent developer experience through autocompletion and inline documentation.

In this tutorial, you will learn what strongly typed Zustand stores look like, why type safety matters in state management, how to implement typed stores and selectors, and the best practices that keep your codebase maintainable as it grows.

Why Type Safety Matters in State Management

State is the single source of truth in any React application. When state is untyped or loosely typed, small mistakes — such as misspelling a property name, passing the wrong argument to an action, or selecting a field that no longer exists — can propagate silently through your components and surface as confusing UI bugs.

TypeScript addresses these issues by enforcing contracts at compile time. With Zustand and TypeScript together, you gain:

Setting Up the Project

Start by creating a React project with TypeScript and installing Zustand. You can use Vite for a fast, modern setup:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm install zustand

Once installed, you are ready to create your first strongly typed store.

Creating a Strongly Typed Store

The most reliable way to type a Zustand store is to define an explicit interface that describes both the state and the actions, then pass that interface as a generic parameter to the create function. This approach ensures that every piece of your store is accounted for in the type system.

Defining the Store Interface

import { create } from 'zustand';

// Define the shape of your state and actions
interface CounterState {
  count: number;
  step: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  setStep: (step: number) => void;
}

// Create the store with the explicit type parameter
export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  step: 1,
  increment: () => set((state) => ({ count: state.count + state.step })),
  decrement: () => set((state) => ({ count: state.count - state.step })),
  reset: () => set({ count: 0, step: 1 }),
  setStep: (step) => set({ step }),
}));

Notice that the set function provided by Zustand is already typed based on the CounterState interface. This means if you try to update a property that does not exist, TypeScript will raise an error immediately.

Using the Store in a Component

import { useCounterStore } from './store/counterStore';

export function Counter() {
  const count = useCounterStore((state) => state.count);
  const step = useCounterStore((state) => state.step);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const setStep = useCounterStore((state) => state.setStep);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <input
        type="number"
        value={step}
        onChange={(e) => setStep(Number(e.target.value))}
      />
    </div>
  );
}

Each selector is strongly typed, so count is inferred as number, increment is inferred as () => void, and so on. There is no need for manual type annotations in the component.

Working with Async Actions

Real applications often need to fetch data asynchronously. Zustand handles async actions gracefully, and TypeScript ensures that the data flowing into your state matches the expected shape.

Defining Async Types

import { create } from 'zustand';

interface User {
  id: number;
  name: string;
  email: string;
}

type Status = 'idle' | 'loading' | 'success' | 'error';

interface UserStore {
  users: User[];
  status: Status;
  error: string | null;
  fetchUsers: () => Promise<void>;
  addUser: (user: User) => void;
}

export const useUserStore = create<UserStore>((set) => ({
  users: [],
  status: 'idle',
  error: null,
  fetchUsers: async () => {
    set({ status: 'loading', error: null });
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/users');
      const data: User[] = await response.json();
      set({ users: data, status: 'success' });
    } catch (err) {
      set({ status: 'error', error: (err as Error).message });
    }
  },
  addUser: (user) => set((state) => ({ users: [...state.users, user] })),
}));

By typing the Status as a union of string literals, TypeScript prevents you from accidentally assigning an invalid status value anywhere in your code.

Selectors and Performance

Zustand encourages selecting only the slices of state you need. This prevents unnecessary re-renders when unrelated parts of the store change. TypeScript plays nicely with selectors by inferring the return type automatically.

Combining Multiple Values

When you need multiple values from the store, you can use a selector that returns an object. However, returning a new object on every render can cause infinite re-render loops. To avoid this, use Zustand's useShallow utility:

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  discount: number;
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  applyDiscount: (discount: number) => void;
}

export const useCartStore = create<CartStore>((set) => ({
  items: [],
  discount: 0,
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  applyDiscount: (discount) => set({ discount }),
}));
import { useShallow } from 'zustand/react/shallow';
import { useCartStore } from './store/cartStore';

export function CartSummary() {
  const { items, discount } = useCartStore(
    useShallow((state) => ({
      items: state.items,
      discount: state.discount,
    }))
  );

  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const total = subtotal - discount;

  return (
    <div>
      <p>Items: {items.length}</p>
      <p>Subtotal: ${subtotal.toFixed(2)}</p>
      <p>Discount: ${discount.toFixed(2)}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

The useShallow hook performs a shallow comparison of the returned object, so the component only re-renders when the actual values of items or discount change.

Slicing Stores for Scalability

As your application grows, a single store can become unwieldy. Zustand supports a slice pattern where each slice manages a specific domain of state. TypeScript makes this pattern safe and composable.

Creating a Typed Slice

import { StateCreator } from 'zustand';

export interface AuthSlice {
  user: { id: string; name: string } | null;
  isAuthenticated: boolean;
  login: (user: { id: string; name: string }) => void;
  logout: () => void;
}

export const createAuthSlice: StateCreator<
  AuthSlice,
  [],
  [],
  AuthSlice
> = (set) => ({
  user: null,
  isAuthenticated: false,
  login: (user) => set({ user, isAuthenticated: true }),
  logout: () => set({ user: null, isAuthenticated: false }),
});

Combining Slices into One Store

import { create } from 'zustand';
import { createAuthSlice, AuthSlice } from './slices/authSlice';
import { createCartSlice, CartSlice } from './slices/cartSlice';

type Store = AuthSlice & CartSlice;

export const useStore = create<Store>()((...a) => ({
  ...createAuthSlice(...a),
  ...createCartSlice(...a),
}));

The StateCreator type from Zustand ensures that each slice is correctly typed and that the combined store satisfies the intersection of all slice interfaces. If a slice is missing a required property, TypeScript will catch it at the point where the store is assembled.

Persisting State with Type Safety

Zustand provides a persist middleware for saving state to localStorage or other storage backends. When using persistence, it is important to type the persisted state so that hydration does not introduce unexpected undefined values.

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsStore {
  theme: 'light' | 'dark';
  fontSize: number;
  setTheme: (theme: 'light' | 'dark') => void;
  setFontSize: (size: number) => void;
}

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      fontSize: 14,
      setTheme: (theme) => set({ theme }),
      setFontSize: (fontSize) => set({ fontSize }),
    }),
    {
      name: 'app-settings',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        theme: state.theme,
        fontSize: state.fontSize,
      }),
    }
  )
);

The partialize function lets you choose exactly which fields are persisted. Because the store is typed, TypeScript verifies that every field you include in partialize actually exists on the store.

Best Practices

Conclusion

Zustand and TypeScript are a natural fit for building robust React applications. By defining explicit interfaces for your stores, using typed selectors, embracing the slice pattern for scalability, and applying middleware like persist with careful type annotations, you create a state management layer that is both flexible and safe. The compiler becomes your safety net, catching mistakes before they reach your users and making refactoring a confident, predictable process. As your application grows, these practices will keep your state logic clean, maintainable, and trustworthy.

— Ad —

Google AdSense will appear here after approval

← Back to all articles