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:

  1. Add one key-value pair to byId
  2. 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


Last updated: 2026-06-14