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:
- Catch errors early: Invalid state updates and selector mismatches are flagged before the code runs.
- Self-documenting code: The store's type signature describes the entire state shape and available actions.
- Safe refactoring: Renaming or removing a state field triggers compiler errors everywhere it is used.
- Better autocompletion: IDEs can suggest property names and action signatures accurately.
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
- Always define an explicit interface: Avoid relying on inference alone. An explicit interface serves as documentation and catches missing properties during refactoring.
- Keep actions inside the store: Co-locating state and actions in the same store keeps logic centralized and testable.
- Use selectors with shallow comparison: Select only what you need and use
useShallowwhen returning objects to prevent unnecessary re-renders. - Type union states explicitly: Use string literal unions or discriminated unions for status fields to make impossible states unrepresentable.
- Split large stores into slices: The slice pattern keeps each domain focused and makes the codebase easier to navigate.
- Avoid storing derived state: Compute derived values in selectors or components rather than storing them, which prevents sync bugs.
- Validate persisted data: When hydrating from localStorage, consider validating the shape of stored data, especially if the schema may have changed between releases.
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.