Skip to main content

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:

  1. Outer function receives:

    • set: Function to update state
    • get: Function to read current state
  2. Middle function receives:

    • next: The next middleware in the chain or setState
  3. 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!