Normalized State
Store every entity in a flat map keyed by ID instead of nesting objects. Updates become O(1), traversal disappears, and re-renders become granular.
The problem I keep seeing
Hierarchical data (comment threads, file trees, org charts, category taxonomies) feels natural to model as nested objects - a comment has replies, a folder has children. The structure matches how you'd draw it on a whiteboard.
It breaks down the first time you need to update a node deep in the tree. Finding it requires a recursive walk. Updating it immutably requires copying every ancestor from the root down. Moving a node between parents is a multi-step mutation that's easy to get wrong - and you typically want it to feel instant, which means pairing it with optimistic updates. And because the root object reference changes on any update, everything subscribed to the root re-renders.
The fix: don't nest. Put every node in a flat Map<string, Node> keyed by ID, and replace embedded children with arrays of child IDs.
Naive approach
Nested objects mirror the logical structure but make every mutation a recursive traversal.
The second cost is the clone cascade. To update node.name immutably, every ancestor from the root down to that node must be copied - otherwise their references don't change and React sees no update:
First improvement
Replace the nested tree with a flat map. Children become arrays of IDs, not embedded objects. Every read and write becomes a map operation.
Why this helps: Lookup, insert, update, and delete are now all O(1) regardless of tree depth. No recursive traversal. Moving a node is three map.set() calls.
Remaining issues
- Root entry point: A flat map has no implicit root. You need a separate
rootIds: string[]array to know which nodes are top-level. - Orphan cleanup: Deleting a folder does not automatically delete its descendants in the map. You need to walk
childIdsrecursively on delete if you want a clean store. - Re-render granularity: If components subscribe to the whole map, every update still triggers a full re-render. Selectors scoped to a single node ID fix this - see the Granular selectors section below.
- API response mismatch: Some APIs return nested trees. You need a normalize step on fetch to flatten them into the map before storing.
Production pattern
A Zustand store with a Map<string, FileNode> and named mutation methods. Each method touches only the nodes it needs - no full-tree copies.
This is how Apollo and Redux Toolkit normalize too
createEntityAdapter use the same principle: a flat map of entities keyed by ID, with selectors that derive structure on read. The pattern predates React - it's how relational databases work.Granular selectors
Normalization only pays off on re-renders if components subscribe to individual nodes, not the whole map. Each row subscribes to its own ID - renaming one file only re-renders that row, not the entire tree.
When I use this
- Use: Any data that is hierarchical or relational - comment threads, file trees, org charts, nested categories, block-based documents (Notion), navigation menus with submenus.
- Use: When multiple parts of the UI reference the same entity by ID (e.g. a user card appears in a post, in a comment, and in a sidebar).
- Skip: Simple flat lists with no relations - a
Post[]array is fine if you're never looking up a post by ID or updating individual posts. - Skip: Read-only data that is fetched and displayed once with no mutations - normalization is about update ergonomics, not read ergonomics.
Decision
Gotchas
- Normalize on ingest, not on render: Flatten the API response as soon as it arrives. Never store a nested tree and flatten on read - you lose the O(1) update benefit and pay the traversal cost on every render.
- Deep delete: When deleting a subtree, collect all descendant IDs first (one pass down via
childIds), then delete them all in a single map update. Don't delete the parent first - you'll lose the child ID list. - Referential equality in selectors:
useFileStore(s => s.nodes.get(id))returns a stable reference as long as that node hasn't changed. If you derive an array (e.g.getChildren), the new array is a new reference every call - memoize it withuseMemoor use a library likereselect. See Memoization. - Circular references: Each node stores its
parentIdalongsidechildIds. This is fine for traversal but means a move operation must update three nodes atomically - don't forget to update the node's ownparentId.
File Browser system design → · Optimistic Updates pattern · All patterns