| name | frontend/state-management-patterns |
| description | Implement state management: Zustand slices pattern, manual immutable updates with spread operators, middleware stack (devtools, subscribeWithSelector), Immer decision matrix (4+ spreads → use Immer), transient updates for 10+ updates/sec. Use when implementing stores or managing complex state. |
Frontend State Management Patterns
Pattern: Zustand + Manual Immutability + Slices Pattern
Use Zustand with manual immutable updates (via spread operators) and the Slices Pattern for all application state management.
Core Architecture
- State Library: Zustand (v5.0.8)
- Immutability: Manual immutable updates using spread operators
- Middleware Stack:
devtools: Redux DevTools integration (development only)
subscribeWithSelector: Fine-grained subscription to state changes
- Custom logging wrapper for action tracking (development only)
- Organization Pattern: Slices Pattern - separate slice creators for different state concerns
- State Access: Granular hooks for specific slices and computed selectors
Slices Pattern
Each concern gets its own slice. Slice creators are pure functions that accept set and get.
Step 1: Define Slice Types
export interface MyFeatureState {
property1: string;
property2: number;
}
export interface MyFeatureActions {
updateProperty1: (value: string) => void;
resetFeature: () => void;
}
export interface MyFeatureSlice {
myFeature: MyFeatureState;
myFeatureActions: MyFeatureActions;
}
Step 2: Create Slice Creator
import type { SliceCreator, StoreType, MyFeatureSlice } from '../types';
export const createMyFeatureSlice: SliceCreator<MyFeatureSlice, StoreType> = (set, get) => ({
myFeature: {
property1: 'default',
property2: 0,
},
myFeatureActions: {
updateProperty1: (value: string) => {
set((state) => ({
myFeature: {
...state.myFeature,
property1: value,
},
}));
},
resetFeature: () => {
set((state) => ({
myFeature: {
...state.myFeature,
property1: 'default',
property2: 0,
},
}));
},
},
});
Step 3: Integrate into Store
import { createMyFeatureSlice } from './slices/my-feature-slice';
export const useStore = create<StoreType>()(
devtools(
subscribeWithSelector((set, get) => {
const myFeatureSlice = createMyFeatureSlice(set, get);
return {
...myFeatureSlice,
};
}),
),
);
Step 4: Export Hooks
export const useMyFeatureState = () => useStore((state) => state.myFeature);
export const useMyFeatureActions = () => useStore((state) => state.myFeatureActions);
Immutable Update Patterns
Update Top-Level Property
set((state) => ({
canvas: {
...state.canvas,
zoom: newZoom,
},
}));
Update Nested Array Element
set((state) => {
const { screens, currentScreenIndex } = state.screens;
const currentScreen = screens[currentScreenIndex];
const updatedScreen = {
...currentScreen,
elements: currentScreen.elements.map((el) => (el.id === id ? { ...el, ...updates } : el)),
};
const newScreens = screens.map((screen, index) => (index === currentScreenIndex ? updatedScreen : screen));
return {
screens: {
...state.screens,
screens: newScreens,
},
};
});
Early Return for Invalid State
set((state) => {
const currentScreen = state.screens.screens[state.screens.currentScreenIndex];
if (!currentScreen) return state;
});
Middleware Configuration
DevTools Middleware
devtools(store, {
enabled: process.env.NODE_ENV === 'development',
name: 'My Feature Store',
});
Logging Wrapper (Development Only)
const withLogging = <T extends (...args: any[]) => any>(fn: T, actionName: string): T => {
if (process.env.NODE_ENV === 'development') {
return ((...args: Parameters<T>) => {
console.info(`[Store] ${actionName}`, { args });
return fn(...args);
}) as T;
}
return fn;
};
Immer Decision Matrix
Use Immer when ANY of these conditions are met:
Criterion 1: Spread Operator Count (4+ spreads)
set((state) => ({
...state,
screens: {
...state.screens,
screens: state.screens.screens.map((screen, idx) =>
idx === currentIndex
? {
...screen,
elements: screen.elements.map((el) => (el.id === id ? { ...el, ...updates } : el)),
}
: screen,
),
},
}));
set((state) =>
produce(state, (draft) => {
const screen = draft.screens.screens[currentIndex];
const element = screen.elements.find((el) => el.id === id);
if (element) Object.assign(element, updates);
}),
);
Criterion 2: Nesting Depth (4+ levels)
Criterion 3: Nested Array Operations (map-within-map)
Decision Matrix Summary
| Update Complexity | Recommendation | Reason |
|---|
| 1-3 spread operators | Manual spreads | Faster, simpler code |
| 4+ spread operators | Use Immer | Less error-prone, faster execution |
| Nested arrays (map-in-map) | Use Immer | Significantly simpler code |
| Team struggling with bugs | Use Immer everywhere | Reduces bugs by 7.5x |
Immer Usage Pattern
import { produce } from 'immer';
updateQuizAnswer: (quizId: string, questionId: string, answerId: string, updates: Partial<Answer>) => {
set((state) =>
produce(state, (draft) => {
const quiz = draft.elements.find((el) => el.id === quizId && el.type === 'quiz');
if (!quiz) return;
const question = quiz.questions.find((q) => q.id === questionId);
if (!question) return;
const answer = question.answers.find((a) => a.id === answerId);
if (answer) {
Object.assign(answer, updates);
}
}),
);
};
Transient Updates for High-Frequency Changes
Use Case: Canvas zoom, pan, drag operations that update state 60+ times per second.
Problem: Normal Zustand updates notify all subscribers, causing excessive re-renders.
Solution: Use transient updates that skip subscriber notification.
Implementation
const useStore = create<CanvasState & { canvasActions: CanvasActions }>()(
subscribeWithSelector((set, get, api) => ({
zoom: 1,
panX: 0,
panY: 0,
isTransient: false,
canvasActions: {
setZoom: (zoom: number, transient = false) => {
if (transient) {
const state = get();
(api as any).state = { ...state, zoom, isTransient: true };
} else {
set({ zoom, isTransient: false });
}
},
commitTransient: () => {
const state = get();
if (state.isTransient) {
set({ isTransient: false });
}
},
},
})),
);
Usage Pattern
function handleCanvasDrag(deltaX: number, deltaY: number) {
const { panX, panY, setPan } = useStore.getState();
setPan(panX + deltaX, panY + deltaY, true);
}
function handleDragEnd() {
const { commitTransient } = useStore.getState().canvasActions;
commitTransient();
}
const zoom = useStore((state) => (state.isTransient ? undefined : state.zoom));
When to Use Transient Updates
| Scenario | Use Transient? |
|---|
| Canvas zoom/pan during drag | Yes |
| Element position during drag | Yes |
| Slider value during drag | Yes |
| Scroll position during scroll | Yes |
| User clicks (single update) | No |
| API responses (single update) | No |
Threshold: Only use for operations that update 10+ times per second.
Testing Slices
import { createCanvasSlice } from './canvas-slice';
describe('Canvas Slice', () => {
it('should set zoom within bounds', () => {
let state: any;
const set = (fn: any) => {
state = fn(state);
};
const get = () => state;
const slice = createCanvasSlice(set as any, get as any);
state = slice;
slice.canvasActions.setZoom(5);
expect(state.canvas.zoom).toBe(3);
});
});
Risks and Mitigations
| Risk | Mitigation |
|---|
| Accidental State Mutations | Code review, ESLint no-param-reassign, TypeScript readonly types |
| Inconsistent Slice Patterns | Document patterns, provide templates, enforce in code review |
| Performance Regression | Use granular selectors, profile with React DevTools, normalize state |
Related Documentation