Introduction to State Management with Zod
Zod is a TypeScript-first schema declaration and validation library. While Zod is not itself a state management library, it has become an essential tool in modern state management workflows because it lets you define the shape of your application state and guarantee that state always conforms to that shape at runtime. This tutorial explores the patterns and libraries that combine Zod with state management to produce safer, more predictable applications.
By the end of this article, you will understand how to model state with Zod schemas, validate state transitions, integrate Zod with popular state libraries like Zustand and Redux Toolkit, and apply best practices that keep your codebase maintainable.
Why State Validation Matters
TypeScript gives you compile-time guarantees, but it cannot protect you from runtime data. State in real applications comes from many untrusted sources: user input, network responses, localStorage, URL parameters, and third-party APIs. Any of these can introduce data that does not match your types, leading to subtle bugs or crashes.
Zod bridges this gap. When you validate state at the boundary where it enters your application, you can trust that the rest of your code is working with data that actually matches your TypeScript types. This is especially valuable in state management, where a single malformed object can propagate through dozens of components.
The Cost of Skipping Validation
- Null reference errors: A field you assumed was always present is suddenly
undefined. - Incorrect rendering: Components render based on stale or malformed data.
- Hard-to-trace bugs: Invalid data flows through several layers before causing a visible problem.
- Security risks: Unvalidated data may reach sensitive operations like database writes.
Modeling Application State with Zod
The first step is to describe your application state as a Zod schema. This schema becomes the single source of truth for both runtime validation and TypeScript type inference.
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
preferences: z.object({
theme: z.enum(["light", "dark"]).default("light"),
notifications: z.boolean().default(true),
}),
});
const SessionSchema = z.object({
user: UserSchema,
token: z.string().min(16),
expiresAt: z.string().datetime(),
});
// Derive the TypeScript type from the schema
type Session = z.infer<typeof SessionSchema>;
Using z.infer means your types and your runtime validation can never drift apart. If you change the schema, the type updates automatically.
Pattern 1: Validating State at Boundaries
The most common pattern is to validate state when it enters the system โ for example, when hydrating from localStorage or after a network request. This keeps your internal state trustworthy.
const STORAGE_KEY = "app-session";
function loadSession(): Session | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const result = SessionSchema.safeParse(JSON.parse(raw));
if (!result.success) {
console.warn("Invalid session in storage", result.error.flatten());
localStorage.removeItem(STORAGE_KEY);
return null;
}
return result.data;
}
function saveSession(session: Session): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
Using safeParse instead of parse lets you handle invalid data gracefully without throwing. This is important in state management because you usually want to recover rather than crash.
Pattern 2: Validating State Transitions
Beyond validating external data, you can use Zod to validate state transitions. This is useful when state changes must follow business rules โ for example, a finite state machine for an order.
const OrderState = z.enum(["draft", "submitted", "paid", "shipped", "cancelled"]);
const OrderSchema = z.object({
id: z.string(),
status: OrderState,
total: z.number().nonnegative(),
items: z.array(z.object({
sku: z.string(),
quantity: z.number().int().positive(),
price: z.number().nonnegative(),
})).min(1),
});
type Order = z.infer<typeof OrderSchema>;
const transitions: Record<string, string[]> = {
draft: ["submitted", "cancelled"],
submitted: ["paid", "cancelled"],
paid: ["shipped"],
shipped: [],
cancelled: [],
};
function transitionOrder(order: Order, next: z.infer<typeof OrderState>): Order {
if (!transitions[order.status].includes(next)) {
throw new Error(`Cannot transition from ${order.status} to ${next}`);
}
const updated = { ...order, status: next };
// Re-validate the entire order after the transition
return OrderSchema.parse(updated);
}
This pattern guarantees that no matter how you mutate state, the result always satisfies the schema. Invalid transitions throw immediately, making bugs easy to locate.
Pattern 3: Zod with Zustand
Zustand is a lightweight state management library that pairs naturally with Zod. You can wrap state updates in validation to ensure the store never holds invalid data.
import { create } from "zustand";
import { z } from "zod";
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]),
fontSize: z.number().min(12).max(24),
language: z.string().min(2),
});
type Settings = z.infer<typeof SettingsSchema>;
interface SettingsStore {
settings: Settings;
updateSettings: (patch: Partial<Settings>) => void;
reset: () => void;
}
const defaultSettings: Settings = {
theme: "light",
fontSize: 14,
language: "en",
};
export const useSettingsStore = create<SettingsStore>((set) => ({
settings: defaultSettings,
updateSettings: (patch) =>
set((state) => {
const next = SettingsSchema.parse({ ...state.settings, ...patch });
return { settings: next };
}),
reset: () => set({ settings: defaultSettings }),
}));
Because updateSettings always runs the merged object through SettingsSchema.parse, the store is guaranteed to contain valid settings. If a caller passes a bad value, the error surfaces at the point of the bad update rather than somewhere downstream.
Creating a Reusable Validated Store
You can generalize this approach into a helper that validates every state update.
import { create, StoreApi, UseBoundStore } from "zustand";
import { ZodType } from "zod";
function createValidatedStore<T>(
schema: ZodType<T>,
initial: T
): UseBoundStore<StoreApi<T>> {
return create<T>((set) => ({
...initial,
}));
}
function validatedSet<T>(schema: ZodType<T>, set: (fn: (s: T) => T) => void) {
return (updater: (s: T) => T) =>
set((state) => schema.parse(updater(state)));
}
This pattern is especially useful in larger apps where multiple stores share the same validation discipline.
Pattern 4: Zod with Redux Toolkit
Redux Toolkit (RTK) is another popular choice. The cleanest integration point is the prepare callback in createSlice, which lets you validate payloads before they reach the reducer.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { z } from "zod";
const TodoSchema = z.object({
id: z.string(),
text: z.string().min(1).max(200),
completed: z.boolean(),
});
type Todo = z.infer<typeof TodoSchema>;
interface TodosState {
items: Todo[];
}
const initialState: TodosState = { items: [] };
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo: {
prepare(text: string) {
const todo = TodoSchema.parse({
id: crypto.randomUUID(),
text,
completed: false,
});
return { payload: todo };
},
reducer(state, action: PayloadAction<Todo>) {
state.items.push(action.payload);
},
},
toggleTodo(state, action: PayloadAction<string>) {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
With this setup, invalid payloads never reach the reducer. The validation happens once, at the action creation boundary.
Pattern 5: Zod with React Context and useReducer
For local or feature-scoped state, useReducer combined with React Context is a common choice. You can validate actions inside the reducer to keep state consistent.
import { createContext, useContext, useReducer, ReactNode } from "react";
import { z } from "zod";
const CartItemSchema = z.object({
productId: z.string(),
name: z.string(),
price: z.number().nonnegative(),
quantity: z.number().int().positive(),
});
type CartItem = z.infer<typeof CartItemSchema>;
const CartStateSchema = z.object({
items: z.array(CartItemSchema),
couponCode: z.string().nullable(),
});
type CartState = z.infer<typeof CartStateSchema>;
type CartAction =
| { type: "ADD_ITEM"; item: CartItem }
| { type: "REMOVE_ITEM"; productId: string }
| { type: "APPLY_COUPON"; code: string }
| { type: "CLEAR" };
function cartReducer(state: CartState, action: CartAction): CartState {
let next: CartState;
switch (action.type) {
case "ADD_ITEM":
next = { ...state, items: [...state.items, action.item] };
break;
case "REMOVE_ITEM":
next = { ...state, items: state.items.filter((i) => i.productId !== action.productId) };
break;
case "APPLY_COUPON":
next = { ...state, couponCode: action.code };
break;
case "CLEAR":
next = { items: [], couponCode: null };
break;
}
return CartStateSchema.parse(next);
}
const CartContext = createContext<CartState | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], couponCode: null });
return <CartContext.Provider value={state}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}
Because the reducer always returns CartStateSchema.parse(next), any bug that would produce invalid state is caught immediately during development.
Pattern 6: Persisted State and Hydration
When state is persisted to localStorage or IndexedDB, schema drift becomes a real risk โ a user may have data saved from an older version of your app. Zod can sanitize or migrate this data on load.
import { persist, createJSONStorage } from "zustand/middleware";
import { create } from "zustand";
import { z } from "zod";
const PreferencesSchema = z.object({
newsletter: z.boolean().default(false),
timezone: z.string().default("UTC"),
}).passthrough();
type Preferences = z.infer<typeof PreferencesSchema>;
interface PreferencesStore extends Preferences {
set: (patch: Partial<Preferences>) => void;
}
export const usePreferences = create<PreferencesStore>()(
persist(
(set) => ({
newsletter: false,
timezone: "UTC",
set: (patch) => set((s) => ({ ...s, ...patch })),
}),
{
name: "preferences",
storage: createJSONStorage(() => localStorage),
// Validate and apply defaults when hydrating from storage
merge: (persisted, current) => {
const parsed = PreferencesSchema.safeParse(persisted);
return { ...current, ...(parsed.success ? parsed.data : {}) };
},
}
)
);
The merge function runs every time the store hydrates. If the persisted shape has changed between releases, Zod either coerces it to the new shape or discards it, preventing crashes.
Pattern 7: Async State from APIs
State often originates from API calls. Validating the response before storing it ensures your state never contains unexpected shapes.
import { create } from "zustand";
import { z } from "zod";
const ProductSchema = z.object({
id: z.string(),
title: z.string(),
price: z.number().nonnegative(),
inStock: z.boolean(),
});
const ProductsResponseSchema = z.array(ProductSchema);
type Product = z.infer<typeof ProductSchema>;
interface ProductsStore {
products: Product[];
loading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
}
export const useProducts = create<ProductsStore>((set) => ({
products: [],
loading: false,
error: null,
fetchProducts: async () => {
set({ loading: true, error: null });
try {
const res = await fetch("/api/products");
const json = await res.json();
const products = ProductsResponseSchema.parse(json);
set({ products, loading: false });
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : "Unknown error" });
}
},
}));
If the API changes its response shape, the parse step fails loudly instead of silently corrupting your UI state.
Best Practices
Validate at Boundaries, Not Everywhere
Validating every internal operation is expensive and noisy. Validate once where untrusted data enters โ API responses, storage, URL params, user input โ and trust the data afterward. Internal reducers and selectors can rely on the type guarantees.
Use safeParse for Recoverable Cases
Use parse when invalid data indicates a programmer error that should fail fast. Use safeParse when invalid data is a realistic runtime condition, such as a malformed API response or stale persisted state.
Co-locate Schemas with State
Keep the Zod schema next to the store or reducer it describes. This makes it easy to see the contract for a piece of state and update both together.
Derive Types from Schemas
Always use z.infer rather than writing a separate TypeScript interface. This eliminates drift between runtime validation and compile-time types.
Use Defaults for Forward Compatibility
When adding new fields to persisted state, give them defaults in the schema. This lets older persisted data hydrate cleanly without manual migration code.
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]).default("light"),
// New field added in v2 โ old persisted data lacks it
compactMode: z.boolean().default(false),
});
Avoid Over-Validation
Do not encode every business rule in a state schema. Schemas should describe structural validity โ the shape and primitive constraints of the data. Complex cross-field business rules are better expressed in dedicated functions or state machines.
Log Validation Errors
When validation fails in production, log the error with enough context to reproduce it. Zod's error object includes a path and a message for every issue.
const result = SessionSchema.safeParse(raw);
if (!result.success) {
result.error.issues.forEach((issue) => {
console.error(`[${issue.path.join(".")}] ${issue.message}`);
});
}
Libraries That Complement Zod
- Zustand: Minimal boilerplate, works well with Zod-wrapped setters and the
persistmiddleware. - Redux Toolkit: Use
preparecallbacks or RTK Query'stransformResponseto validate payloads. - Jotai / Recoil: Validate atoms in their
readorwritefunctions when atoms derive from external sources. - XState: Combine Zod with state machines by validating context and event payloads at transitions.
- React Hook Form: Pairs with Zod via
@hookform/resolvers/zodfor form state that feeds directly into application state. - TanStack Query: Use Zod in
selectortransformoptions to validate server state before it reaches your components.
Conclusion
Zod is not a replacement for state management libraries, but it is a powerful companion to them. By defining your state as schemas and validating at every boundary โ API responses, persisted storage, user input, and even state transitions โ you turn runtime uncertainty into compile-time confidence. Whether you use Zustand, Redux Toolkit, React Context, or a state machine library, the patterns in this tutorial give you a consistent way to keep state valid, recover gracefully from bad data, and evolve your schemas safely over time. The result is an application that fails loudly where it should, stays silent where it can, and remains trustworthy as it grows.