State Management
OpenGPEX uses a Normalized State architecture powered by Immer to manage all domain entities (frames, layers) with O(1) lookups, minimal undo patches, and zero unnecessary re-renders.
Core Data Structure
The fundamental building block is NormalizedState<T>:
export interface NormalizedState<T> {
byId: Record<string, T>;
order: string[];
}
byId— A hash map keyed by entity ID. Provides O(1) lookups and updates.order— A string array of IDs that defines rendering/Z-index order.
Why Normalized State?
1. O(1) Property Updates
With nested arrays, finding a layer requires iterating the entire tree. With normalized state:
// Direct O(1) access — no searching
state.frames.byId[frameId].layers.byId[layerId].opacity = 0.5;
2. Efficient Reordering
Changing layer Z-order only mutates the order array — layer objects remain referentially stable, preventing unnecessary React re-renders.
3. Minimal Undo Patches
With arrays, inserting at index 0 shifts every subsequent element, generating massive Immer patches. With normalized state, insertion produces exactly 2 patches:
- Add one key-value pair to
byId - Insert one string into
order
State Tree Shape
interface EditorState {
frames: NormalizedState<Frame>; // All artboards
activeFrameId: string | null; // Currently active frame
// Per-frame, layers are nested normalized state:
// frames.byId[id].layers: NormalizedState<Layer>
interaction: InteractionState; // Current tool/mode
ui: UIState; // Panel toggles, theme, etc.
history: HistoryState; // Undo/redo stack
}
CRUD Operations
| Operation | byId |
order |
|---|---|---|
| Add | Insert object | Push ID |
| Remove | delete byId[id] |
filter(x => x !== id) |
| Update | Mutate byId[id].prop |
No change |
| Reorder | No change | Splice/rearrange array |
Code Examples
// Add a layer
actions.addLayers(frameId, [newLayer], insertIndex);
// Update a layer property
actions.updateLayer(frameId, layerId, { opacity: 0.7 });
// Remove layers
actions.removeLayers(frameId, [layerId1, layerId2]);
// Batch update (multiple layers at once)
actions.batchUpdateLayers(frameId, [
{ id: 'l1', patch: { visible: false } },
{ id: 'l2', patch: { locked: true } },
]);
Rendering Pattern
When rendering in React, always iterate via order:
// ✅ Correct: iterate order, lookup by ID
frames.order.map(id => <FrameView key={id} frame={frames.byId[id]} />)
// ❌ Wrong: cannot .map() on a normalized object
frames.map(frame => <FrameView data={frame} />)
Store Architecture
The store is built using an Immer + Zustand-like custom pattern:
const useEditorStore = create<EditorState>((set, get) => ({
// State fields...
// Reducer: all mutations go through Immer produce()
dispatch: (action) => set(produce(state => reducer(state, action))),
}));
Selector Pattern
Components subscribe to minimal state slices via selectors:
// Only re-renders when this specific layer changes
const layer = useEditorState(s =>
s.frames.byId[frameId]?.layers.byId[layerId]
);
Design Rules
📌 Golden Rule: Normalize domain entities, keep UI state local.
When to normalize:
- Entities referenced across multiple components (frames, layers)
- Data that needs undo/redo tracking
- Collections that get reordered frequently
When NOT to normalize:
- Local component state (dropdown open/closed)
- Transient interaction data (mouse position)
- Read-only display-only arrays
Next Steps
- Actions & Commands — How state mutations are dispatched
- Fast Track — 60fps volatile state bypass
- Undo/Redo — How patches enable TimeTravel
Last updated: 2026-06-14