Middleware Guide
Learn how to use Zustic's middleware system to extend functionality.
What is Middleware?
Middleware are functions that intercept and can modify state updates. They're useful for:
- Logging - Track state changes
- Persistence - Save state automatically
- Validation - Enforce state constraints
- DevTools Integration - Debug state changes
- Time Travel - Replay actions
- Analytics - Track user actions
Middleware Type Definition
Zustic provides a built-in Middleware type for full type safety:
import { type Middleware } from "zustic";
type Middleware<T> = (
set: (partial: T | ((state: T) => T)) => void,
get: () => T
) => (
next: (partial: T | ((state: T) => T)) => void
) => (partial: T | ((state: T) => T)) => void | Promise<void>
Breaking it down:
-
Outer function receives:
set: Function to update stateget: Function to read current state
-
Middle function receives:
next: The next middleware in the chain or setState
-
Inner function receives:
partial: The actual update (object or function)
Creating a Middleware
Basic Logger Middleware with Types
import { type Middleware } from "zustic";
type AppState = {
count: number;
name: string;
};
const logger: Middleware<AppState> = (set, get) => (next) => async (partial) => {
// Before update
console.log('Previous state:', get());
console.log('Update:', partial);
// Call next middleware or setState
await next(partial);
// After update
console.log('Updated state:', get());
};
Using Middleware
Pass middleware as the second parameter to create:
import { type Middleware } from "zustic";
type CounterState = {
count: number;
increment: () => void;
};
// Define middleware with proper type
const logger: Middleware<CounterState> = (set, get) => (next) => async (partial) => {
console.log('Previous state:', get());
await next(partial);
console.log('Updated state:', get());
};
// Use single middleware
const useStore = create(
(set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
[logger] // Single middleware in array
);
Multiple Middleware in Array
Pass multiple middleware in an array - they execute left to right:
import { type Middleware } from "zustic";
type AppState = {
user: { name: string; email: string };
isLoading: boolean;
};
// Middleware 1: Logging
const loggerMiddleware: Middleware<AppState> = (set, get) => (next) => async (partial) => {
console.log('Update:', partial);
await next(partial);
};
// Middleware 2: Validation
const validationMiddleware: Middleware<AppState> = (set, get) => (next) => async (partial) => {
if (typeof partial === 'function') {
return await next(partial);
}
if (partial.user?.email && !partial.user.email.includes('@')) {
console.warn('Invalid email');
return;
}
console.log('Validation passed');
await next(partial);
};
// Middleware 3: Persistence
const persistMiddleware: Middleware<AppState> = (set, get) => (next) => async (partial) => {
await next(partial);
localStorage.setItem('app-state', JSON.stringify(get()));
console.log('Persisted');
};
// Use multiple middleware
const useAppStore = create(
(set, get) => ({
user: { name: '', email: '' },
isLoading: false,
setUser: (user) => set((state) => ({ ...state, user })),
}),
[loggerMiddleware, validationMiddleware, persistMiddleware]
// Execution order:
// loggerMiddleware → validationMiddleware → persistMiddleware → setState
);
Middleware are applied from left to right, each wrapping the next.
Common Middleware Patterns
1. Logging Middleware
Track all state changes:
const loggerMiddleware = (set, get) => (next) => async (partial) => {
const prev = get();
const timestamp = new Date().toISOString();
console.group(` State Update - ${timestamp}`);
console.log('Previous:', prev);
console.log('Update:', partial);
await next(partial);
console.log('New:', get());
console.groupEnd();
};
2. Persistence Middleware with localStorage
Auto-save to localStorage with type safety:
import { type Middleware } from "zustic";
type PersistenceState = {
count: number;
name: string;
};
const persistMiddleware: Middleware<PersistenceState> = (set, get) => (next) => async (partial) => {
await next(partial);
try {
const state = get();
localStorage.setItem('app-state', JSON.stringify(state));
console.log('Saved to localStorage');
} catch (error) {
console.error('Failed to persist:', error);
}
};
// Usage:
const useStore = create(
(set, get) => {
const saved = localStorage.getItem('app-state');
const initialState = saved ? JSON.parse(saved) : {};
return {
count: initialState.count ?? 0,
name: initialState.name ?? '',
increment: () => set((state) => ({ ...state, count: state.count + 1 })),
setName: (name: string) => set((state) => ({ ...state, name })),
};
},
[persistMiddleware]
);
2b. Persistence Middleware with Custom Storage
Use custom storage service with typed keys:
import { type Middleware } from "zustic";
import storage, { type StorageKey } from "@src/core/storage/storage";
// Generic persist middleware factory
export const persist = <T extends object>(key: StorageKey): Middleware<T> =>
(_set, get) => (next) => (partial) => {
next(partial);
try {
const state = get() as any;
storage.setItem(key, JSON.stringify(state?.data || []));
console.log(`Persisted ${key} to storage`);
} catch (error) {
console.error(`Failed to persist ${key}:`, error);
}
};
// Usage with type-safe storage keys:
type UserState = {
user: {
id: string;
name: string;
email: string;
};
posts: Array<{ id: number; title: string }>;
};
const useUserStore = create(
(set, get) => ({
user: null as any,
posts: [],
setUser: (user: any) => set((state) => ({ ...state, user })),
addPost: (post: any) => set((state) => ({
...state,
posts: [...state.posts, post]
})),
}),
[persist<UserState>('user-data')]
);
3. Validation Middleware
Validate updates before applying:
const validateMiddleware = (set, get) => (next) => async (partial) => {
const state = get();
const updates = typeof partial === 'function' ? partial(state) : partial;
// Custom validation logic
if ('age' in updates && updates.age < 0) {
console.warn(' Age cannot be negative');
return;
}
if ('email' in updates && !updates.email.includes('@')) {
console.warn(' Invalid email');
return;
}
await next(partial);
console.log(' Validation passed');
};
3b. Permission-Based Middleware
Control state updates based on user permissions:
import { type Middleware } from "zustic";
type PermissionMiddlewareState = {
adminData: any;
publicData: any;
role: 'admin' | 'user' | 'guest';
};
// Permission-based middleware factory
export const requiredPermission = (permission: string): Middleware<PermissionMiddlewareState> =>
(set, get) => (next) => async (partial) => {
const state = get();
const userRole = state.role;
// Check if user has required permission
const hasPermission = userRole === 'admin' || permission === 'public';
if (!hasPermission) {
console.warn(`Permission denied: requires ${permission}`);
return;
}
console.log(`Permission granted for ${permission}`);
await next(partial);
};
// Usage with multiple permission middlewares in array:
type AppState = {
adminData: { users: any[] };
publicData: { posts: any[] };
role: 'admin' | 'user' | 'guest';
};
const useStore = create(
(set, get) => ({
adminData: { users: [] },
publicData: { posts: [] },
role: 'user' as const,
setAdminData: (data: any) => set((state) => ({ ...state, adminData: data })),
setPublicData: (data: any) => set((state) => ({ ...state, publicData: data })),
}),
[
requiredPermission('admin'), // Check admin permissions
requiredPermission('public'), // Check public permissions
persistMiddleware // Persist to storage
]
);
4. Time-Travel Middleware
Store history for debugging:
const historyMiddleware = (set, get) => {
let history = [get()];
let historyIndex = 0;
return (next) => async (partial) => {
await next(partial);
const state = get();
history = history.slice(0, historyIndex + 1);
history.push(state);
historyIndex++;
console.log(`History: ${historyIndex + 1}/${history.length}`);
};
};
5. Debounce Middleware
Debounce frequent updates:
const debounceMiddleware = (ms: number) => {
let timeoutId: NodeJS.Timeout;
return (set, get) => (next) => async (partial) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(async () => {
await next(partial);
console.log('Debounced update applied');
}, ms);
};
};
// Usage:
const useStore = create(
(set, get) => ({
searchQuery: '',
setQuery: (query: string) => set({ searchQuery: query }),
}),
[debounceMiddleware(300)] // Wait 300ms before updating
);
6. Async Middleware
Handle async operations:
const asyncMiddleware = (set, get) => (next) => async (partial) => {
console.log('Processing...');
try {
await next(partial);
console.log(' Update completed');
} catch (error) {
console.error(' Update failed:', error);
}
};
7. Analytics Middleware
Track user actions:
const analyticsMiddleware = (set, get) => (next) => async (partial) => {
const actionName = typeof partial === 'function'
? 'update'
: Object.keys(partial)[0];
// Send to analytics service
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({
action: actionName,
timestamp: Date.now(),
previousState: get(),
}),
});
await next(partial);
};
Advanced Patterns
Immer Middleware
Enable immutable-style updates with Immer for easier nested state mutations:
import produce from 'immer';
const immerMiddleware = (set, get) => (next) => async (partial) => {
const update = typeof partial === 'function' ? partial(get()) : partial;
// Use Immer to produce the next state
const nextState = produce(get(), (draft) => {
Object.assign(draft, update);
});
await next(() => nextState);
};
// Usage: Update nested state easily
type AppState = {
user: {
profile: {
name: string;
email: string;
settings: {
notifications: boolean;
theme: 'light' | 'dark';
};
};
};
posts: Array<{ id: number; title: string; likes: number }>;
};
const useStore = create(
(set, get) => ({
user: {
profile: {
name: 'John',
email: 'john@example.com',
settings: {
notifications: true,
theme: 'light'
}
}
},
posts: [],
// With Immer middleware, you can mutate directly
updateUserName: (name: string) => set((state) => {
// This looks like mutation but is actually immutable!
state.user.profile.name = name;
return state
}),
updateSettings: (theme: 'light' | 'dark') => set((state) => {
state.user.profile.settings.theme = theme;
return state
}),
incrementPostLikes: (postId: number) => set((state) => {
const post = state.posts.find(p => p.id === postId);
if (post) {
post.likes += 1;
}
}),
addPost: (title: string) => set((state) => {
state.posts.push({
id: Date.now(),
title,
likes: 0
});
return state
})
}),
[immerMiddleware]
);
// In component - update nested state easily
export function UserSettings() {
const { user, updateUserName, updateSettings } = useStore();
return (
<div>
<input
value={user.profile.name}
onChange={(e) => updateUserName(e.target.value)}
/>
<select
value={user.profile.settings.theme}
onChange={(e) => updateSettings(e.target.value as 'light' | 'dark')}
>
<option>light</option>
<option>dark</option>
</select>
</div>
);
}
Benefits of Immer Middleware:
- Write mutation-style code (easier to read)
- Get immutability guarantees (no accidental mutations)
- Simpler nested state updates (no deep spreads)
- Works with TypeScript (full type safety)
- No need to manually spread nested objects
Before (without Immer):
// Deep nesting makes this verbose
updateSettings: (theme: 'light' | 'dark') => set((state) => ({
...state,
user: {
...state.user,
profile: {
...state.user.profile,
settings: {
...state.user.profile.settings,
theme
}
}
}
}))
After (with Immer):
// Clean and simple mutation-style syntax
updateSettings: (theme: 'light' | 'dark') => set((state) => {
state.user.profile.settings.theme = theme;
return state
})
Conditional Middleware
const conditionalMiddleware = (shouldLog: boolean) => (set, get) => (next) => async (partial) => {
if (shouldLog) {
console.log('Update:', partial);
}
await next(partial);
};
// Usage:
const useStore = create(
(set, get) => ({...}),
[conditionalMiddleware(process.env.NODE_ENV === 'development')]
);
Middleware with Rollback
const rollbackMiddleware = (set, get) => (next) => async (partial) => {
const previousState = get();
try {
await next(partial);
} catch (error) {
console.error('Error occurred, rolling back:', error);
set(previousState);
throw error;
}
};
Rate Limiting
const rateLimitMiddleware = (maxUpdates: number, window: number) => {
const updates: number[] = [];
return (set, get) => (next) => async (partial) => {
const now = Date.now();
const recentUpdates = updates.filter(t => now - t < window);
if (recentUpdates.length >= maxUpdates) {
console.warn(' Rate limit exceeded');
return;
}
updates.push(now);
await next(partial);
};
};
// Usage: Max 5 updates per 1 second
const useStore = create(
(set, get) => ({...}),
[rateLimitMiddleware(5, 1000)]
);
Best Practices
1. Keep Middleware Pure
Middleware should not have side effects outside of logging/persistence:
// Good
const middleware = (set, get) => (next) => async (partial) => {
console.log('Updating');
await next(partial);
};
// Bad - modifying external state
let counter = 0;
const badMiddleware = (set, get) => (next) => async (partial) => {
counter++; // Side effect!
await next(partial);
};
2. Handle Errors
const safeMiddleware = (set, get) => (next) => async (partial) => {
try {
await next(partial);
} catch (error) {
console.error('Middleware error:', error);
// Handle gracefully
}
};
3. Avoid Infinite Loops
// Bad - causes infinite loop
const badMiddleware = (set, get) => (next) => async (partial) => {
await next(partial);
set(partial); // Calls middleware again!
};
// Good - call next only once
const goodMiddleware = (set, get) => (next) => async (partial) => {
await next(partial);
console.log('Update complete');
};
4. Order Matters
// Different order = different behavior
const useStore1 = create((set) => ({...}), [loggerMiddleware, persistMiddleware]);
const useStore2 = create((set) => ({...}), [persistMiddleware, loggerMiddleware]);
// useStore1 logs, then persists
// useStore2 persists, then logs
Testing Middleware
describe('loggerMiddleware', () => {
it('should log state changes', async () => {
const logs: any[] = [];
const originalLog = console.log;
console.log = (...args) => logs.push(...args);
const useStore = create(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
[loggerMiddleware]
);
const store = useStore.getState();
store.increment();
expect(logs.length).toBeGreaterThan(0);
console.log = originalLog;
});
});
Performance Considerations
1. Async Middleware
Be careful with async middleware - they can slow down updates:
// Lighter weight - sync logging
const syncLogger = (set, get) => (next) => (partial) => {
console.log('Update:', partial);
next(partial);
};
// Heavier - async operations
const asyncLogger = (set, get) => (next) => async (partial) => {
console.log('Update:', partial);
await next(partial);
};
2. Avoid Heavy Computations
// Don't do heavy work in middleware
const badMiddleware = (set, get) => (next) => async (partial) => {
const expensiveCalculation = await runHeavyComputation();
await next(partial);
};
// Keep middleware lightweight
const goodMiddleware = (set, get) => (next) => async (partial) => {
console.log('Updating');
await next(partial);
};
Summary
Middleware in Zustic provides powerful extensibility for:
- Debugging and monitoring
- Persistence and synchronization
- Validation and constraints
- Analytics and tracking
- Time travel and history
Use them to build robust, observable state management!