Flattening Nested Data Structures

Deeply nested state means updating a single item touches the entire tree. Data normalization -- moving from nested arrays to flat maps keyed by ID -- is the fix, and it makes your state updates faster and your code easier to follow.

June 7, 20265 min read1 / 2

I kept reaching into nested objects to update things, and every update felt harder than it should be.

A trip planner where each destination has a list of activities seemed natural to model as nested data. Until I had to rename one activity buried three levels deep. The update function looked like archaeology.

The Problem with Deep Nesting

Here is the nested shape:

TypeScript
type TripState = { destinations: { id: string; name: string; activities: { id: string; label: string; done: boolean; }[]; }[]; };

Updating one activity's done flag requires threading through the entire structure:

TypeScript
case 'ACTIVITY_TOGGLED': return { ...state, destinations: state.destinations.map(dest => dest.id !== action.destinationId ? dest : { ...dest, activities: dest.activities.map(act => act.id !== action.activityId ? act : { ...act, done: !act.done } ), } ), };

Four levels of spread. The destinations array is rebuilt. Every component that reads destinations -- even one that only cares about destination names -- sees a new reference and re-renders.

The performance cost is not just the update itself. It is every component downstream of anything in the tree.

Normalizing the Shape

Data normalization separates the entities and relates them by ID. The same shape that an entity relationship diagram uses -- flat tables, foreign keys, no nesting.

TypeScript
type TripState = { destinations: { id: string; name: string; }[]; activities: { id: string; label: string; done: boolean; destinationId: string; // foreign key }[]; };

The same update is now direct:

TypeScript
case 'ACTIVITY_TOGGLED': return { ...state, activities: state.activities.map(act => act.id !== action.activityId ? act : { ...act, done: !act.done } ), };

One level of spread. Only activities gets a new reference. A component that reads only destinations does not re-render at all.

Before and after: left column shows deeply nested destination objects with activities arrays inside, causing full tree updates; right column shows flat destinations array and flat activities array related by destinationId, causing targeted updates ExpandBefore and after: left column shows deeply nested destination objects with activities arrays inside, causing full tree updates; right column shows flat destinations array and flat activities array related by destinationId, causing targeted updates

Array vs Object: The Lookup Trade-Off

Flat arrays are O(n) to look up by ID. When you need O(1) lookups, normalize into an object keyed by ID:

TypeScript
type TripState = { destinations: Record<string, { name: string }>; destinationIds: string[]; // preserves order activities: Record<string, { label: string; done: boolean; destinationId: string }>; }; // O(1) lookup const dest = state.destinations[destId]; // O(1) update case 'ACTIVITY_TOGGLED': return { ...state, activities: { ...state.activities, [action.activityId]: { ...state.activities[action.activityId], done: !state.activities[action.activityId].done, }, }, };

The trade-off: objects do not preserve insertion order reliably, so a separate destinationIds array is needed to maintain display order. An array is simpler and preserves order automatically.

Start with an array. Switch to an object when you have a measurable lookup bottleneck.

Cascade Deletes

When you delete a parent, its children become orphans. Normalizing makes this explicit:

TypeScript
case 'DESTINATION_DELETED': return { ...state, destinations: state.destinations.filter(d => d.id !== action.destinationId), // Clean up orphaned activities activities: state.activities.filter(a => a.destinationId !== action.destinationId), };

With the nested structure, deleting a destination automatically removed its activities (they were inside it). With the normalized structure, you must do it explicitly. This is a feature, not a limitation. It forces the question: what happens to the activities? Are they archived? Deleted? Moved? Normalization makes you decide.

Branded Types: Catching ID Mix-Ups at Compile Time

Once IDs are everywhere as foreign keys, you need a way to prevent accidentally passing a destinationId where an activityId is expected. TypeScript's branded types solve this with zero runtime cost.

TypeScript
type Brand<T, B extends string> = T & { readonly _brand: B }; type DestinationId = Brand<string, 'DestinationId'>; type ActivityId = Brand<string, 'ActivityId'>;

When you create a new entity, explicitly brand its ID:

TypeScript
function createDestination(name: string) { return { id: crypto.randomUUID() as DestinationId, name, }; } function createActivity(label: string, destinationId: DestinationId) { return { id: crypto.randomUUID() as ActivityId, label, done: false, destinationId, // TypeScript confirms this is a DestinationId, not any string }; }

Trying to pass an ActivityId where a DestinationId is expected is now a compile error. The type system enforces what the entity relationship diagram declares.

For teams: const enum is a useful complement to branded types when you have a fixed set of action types or status values. The enum enforces consistency across the team, and const enum compiles to plain string literals at runtime -- no extra object overhead.

The normalization patterns here directly enable the undo/redo system covered in Undo and Redo with Events.

The Essentials

  1. Deep nesting creates O(n×m) updates that touch the entire tree. Flattening to related arrays (or objects keyed by ID) makes updates targeted and O(n), and stops unrelated components from re-rendering.
  2. Normalize exactly like an ERD: flat entities, foreign keys, no embedding. The model you draw before writing code maps directly to the state shape you should use in code.
  3. Cascade deletes become explicit. When you delete a parent in a normalized structure, orphaned children must be removed manually. This forces a decision about what happens to child data -- and eliminates impossible states like activities belonging to non-existent destinations.

Further Reading and Watching