| name | frontend/data-flow-patterns |
| description | Enforce unidirectional flow: state → UI (selectors) → actions → updates. Prevent direct store access from components, use granular selectors, implement reselect for complex filtering/sorting/aggregation. Use when designing component data flow or optimizing re-renders. |
Frontend Data Flow Patterns
Pattern: Unidirectional Flow + Immutable Updates + Action Pattern
Use unidirectional data flow with immutable updates and action-based state changes for all state management and component communication.
Core Architecture
- Data Flow Direction: State → UI (via selectors) → User Actions → Action Functions → State Updates → UI Re-renders
- Immutability Strategy: Manual immutable updates using spread operators
- Action Organization: Grouped actions by slice concern (
canvasActions, elementsActions)
- State Access: Selector-based subscriptions with computed selectors
- Component Communication: Through shared Zustand state (no direct references)
- Single Source of Truth: Zustand store is the authoritative state source
Unidirectional Data Flow
┌─────────────────────────────────────────────────────┐
│ Zustand Store │
│ (Single Source of Truth) │
└──────────────────┬──────────────────────────────────┘
│ Selectors (subscribe)
↓
┌─────────────────────┐
│ React Component │
│ (reads state via │
│ selectors) │
└──────────┬──────────┘
│ User Interaction
↓
┌─────────────────────┐
│ Action Function │
│ (grouped by slice) │
└──────────┬──────────┘
│ set((state) => newState)
↓
┌─────────────────────┐
│ Immutable Update │
│ (spread operators) │
└──────────┬──────────┘
│
↓
┌─────────────────────┐
│ State Changed │
│ (Zustand notifies) │
└──────────┬──────────┘
│
↓ (loop back to top)
Component Re-renders
Core Principles
Principle 1: Immutable Updates Only
state.canvas.zoom = 2
{ ...state.canvas, zoom: 2 }
- Use
.map(), .filter(), spread operator for arrays
- Early returns: Return
state unchanged for invalid operations
- Chain spread operators for nested objects
Principle 2: Action-Based State Changes
- All state changes go through named action functions
- No direct
set() calls from components
- Actions grouped by slice for discoverability
- Action names describe intent (verb-noun:
addElement, setZoom, deleteScreen)
- Actions encapsulate business logic (validation, derived updates)
Principle 3: Component Decoupling
- Components read state via selectors (never access store directly)
- Components dispatch actions (never call other components)
- Siblings communicate through shared state (no prop drilling, no callbacks)
- Parent-child communication: props down, actions up (via store)
Principle 4: Computed State via Selectors
- Derived state computed in selector functions
- Selectors are pure functions (no side effects)
- Selectors compose (reuse other selectors)
- Zustand memoizes selector results (prevents unnecessary re-renders)
Data Flow Patterns
Pattern 1: Component Reads State via Selector
function ZoomControls() {
const zoom = useStoryEditorStore((state) => state.canvas.zoom);
const { zoomIn, zoomOut, resetZoom } = useStoryEditorStore((state) => state.canvasActions);
return (
<div>
<span>Zoom: {Math.round(zoom * 100)}%</span>
<button onClick={zoomIn}>+</button>
<button onClick={zoomOut}>-</button>
<button onClick={resetZoom}>Reset</button>
</div>
);
}
Pattern 2: Component Dispatches Actions
function ElementPositionControl() {
const selectedElement = useStoryEditorStore(selectSelectedElement);
const { updateElement } = useStoryEditorStore((state) => state.elementsActions);
const handleMove = (deltaX: number, deltaY: number) => {
if (!selectedElement) return;
updateElement(selectedElement.id, {
x: selectedElement.x + deltaX,
y: selectedElement.y + deltaY,
});
};
return <button onClick={() => handleMove(10, 0)}>Move Right</button>;
}
Pattern 3: Sibling Components Communicate via State
function ElementList() {
const { selectElement } = useStoryEditorStore((state) => state.selectionActions);
return (
<div>
{elements.map((el) => (
<div onClick={() => selectElement(el.id)}>{el.type}</div>
))}
</div>
);
}
function PropertiesPanel() {
const selectedElement = useStoryEditorStore(selectSelectedElement);
if (!selectedElement) return <div>No selection</div>;
return <div>Selected: {selectedElement.type}</div>;
}
Selector Patterns
Simple Property Selector
export const selectZoom = (state: StoryEditorStore) => state.canvas.zoom;
export const selectCurrentTool = (state: StoryEditorStore) => state.canvas.tool;
Derived State Selector
export const selectCurrentScreen = (state: StoryEditorStore): StoryScreen | null => {
const { screens, currentScreenIndex } = state.screens;
return screens[currentScreenIndex] || null;
};
export const selectCurrentScreenElements = (state: StoryEditorStore): StoryElementUnion[] => {
const currentScreen = selectCurrentScreen(state);
return currentScreen?.elements || [];
};
Parameterized Selector (Factory)
export const selectElementById =
(id: string) =>
(state: StoryEditorStore): StoryElementUnion | null => {
const elements = selectCurrentScreenElements(state);
return elements.find((element) => element.id === id) || null;
};
const element = useStoryEditorStore(selectElementById('element-123'));
Composed Selector
export const selectSelectedElement = (state: StoryEditorStore): StoryElementUnion | null => {
const selectedId = selectSelectedElementId(state);
if (!selectedId) return null;
return selectElementById(selectedId)(state);
};
Memoization with Reselect
Use Case: Complex selectors with expensive computations (filtering, sorting, aggregating large arrays).
Installation
pnpm add reselect --filter @litskills/story-state
Basic Memoized Selector
import { createSelector } from 'reselect';
const selectScreens = (state: StoryEditorStore) => state.screens.screens;
const selectCurrentIndex = (state: StoryEditorStore) => state.screens.currentScreenIndex;
const selectSelectedId = (state: StoryEditorStore) => state.selection.selectedElementId;
export const selectSelectedElementMemoized = createSelector([selectScreens, selectCurrentIndex, selectSelectedId], (screens, currentIndex, selectedId) => {
const currentScreen = screens[currentIndex];
if (!currentScreen || !selectedId) return null;
return currentScreen.elements.find((el) => el.id === selectedId) || null;
});
Memoized Filtering/Sorting
export const selectVisibleElements = (state: StoryEditorStore) => {
const elements = selectCurrentScreenElements(state);
return elements.filter((el) => !el.hidden).sort((a, b) => a.zIndex - b.zIndex);
};
export const selectVisibleElementsMemoized = createSelector([selectCurrentScreenElements], (elements) => {
return elements.filter((el) => !el.hidden).sort((a, b) => a.zIndex - b.zIndex);
});
When to Use Memoization
| Selector Operation | Use Reselect? | Reason |
|---|
Simple property access (state.canvas.zoom) | No | Zustand shallow equality is sufficient |
| Filtering small arrays (<10 items) | No | Computation cost negligible |
| Filtering large arrays (100+ items) | Yes | Significant computation savings |
| Sorting operations | Yes | Always expensive |
| Multiple dependent selectors | Yes | Prevents cascade recomputations |
| Aggregations (sum, count, group) | Yes | Expensive operations benefit from caching |
Performance Comparison
| Selector Complexity | Without Memoization | With Reselect |
|---|
| Simple property | 0.01ms | 0.01ms |
| Filtering 100 elements | 0.5ms (every render) | 0.002ms (cached) |
| Sorting + filtering 100 | 1.2ms (every render) | 0.002ms (cached) |
| Aggregating 1000 items | 15ms (every render) | 0.002ms (cached) |
Component Subscription Patterns
Subscribe to Specific Property
const zoom = useStoryEditorStore((state) => state.canvas.zoom);
Subscribe to Actions Only
const { addElement, updateElement } = useStoryEditorStore((state) => state.elementsActions);
Subscribe with Computed Selector
const currentScreen = useStoryEditorStore(selectCurrentScreen);
const selectedElement = useStoryEditorStore(selectSelectedElement);
Action Organization
Group Actions by Slice
export const createCanvasSlice: SliceCreator<CanvasSlice, StoryEditorStore> = (set, get) => ({
canvas: {
zoom: 1,
tool: 'select',
},
canvasActions: {
setZoom: (zoom: number) => {
},
zoomIn: () => {
},
zoomOut: () => {
},
setTool: (tool) => {
},
},
});
Action Naming Convention
elementsActions: {
addElement: (element) => { },
updateElement: (id, updates) => { },
deleteElement: (id) => { },
duplicateElement: (id) => { },
moveElement: (id, x, y) => { },
}
Immutable Update Patterns
Add Item to Array
addElement: (element: StoryElementUnion) => {
set((state) => {
const { screens, currentScreenIndex } = state.screens;
const currentScreen = screens[currentScreenIndex];
if (!currentScreen) return state;
const updatedScreen = {
...currentScreen,
elements: [...currentScreen.elements, element],
};
const newScreens = screens.map((screen, index) => (index === currentScreenIndex ? updatedScreen : screen));
return {
screens: {
...state.screens,
screens: newScreens,
},
};
});
};
Remove Item from Array
deleteElement: (id: string) => {
set((state) => {
const { screens, currentScreenIndex } = state.screens;
const currentScreen = screens[currentScreenIndex];
if (!currentScreen) return state;
const updatedScreen = {
...currentScreen,
elements: currentScreen.elements.filter((el) => el.id !== id),
};
const newScreens = screens.map((screen, index) => (index === currentScreenIndex ? updatedScreen : screen));
return {
screens: {
...state.screens,
screens: newScreens,
},
};
});
};
Benefits
| Category | Benefit |
|---|
| Predictability | Clear data flow direction makes debugging easier |
| Debuggability | Time-travel debugging with DevTools |
| Performance | Selector-based subscriptions prevent unnecessary re-renders |
| Maintainability | Consistent pattern across all state slices |
Risks and Mitigations
| Risk | Mitigation |
|---|
| Accidental State Mutations | Code review, ESLint no-param-reassign, TypeScript readonly |
| Action Explosion | Generic update actions, action granularity guidelines |
| Component Over-Rendering | Granular selectors, subscribe to specific properties, React.memo |
| Inconsistent Patterns | Document patterns, code review, slice templates |
Related Documentation