Skip to main content

Command Palette

Search for a command to run...

React recursive components

Updated
16 min readView as Markdown
React recursive components

How a recursive JSON editor keeps track of itself

Find the app deployed here and the code here.

The app is two panes.

On the left, you edit a tree of key: value rows.

On the right, the JSON updates as you type.

Drag a row to reorder it. The JSON follows. Change a field's type. The JSON follows. Import a file. The whole tree rebuilds.

So here is the question underneath all of it:

How does the right side always match the left,
even while you are dragging rows around
and renaming the very keys used to find them?

There is one more thing to say up front, because it changes how you read everything below.

There is no server.

No backend. No database. No network call anywhere in the flow. Every step in this post runs in your browser, in JavaScript, on the data sitting in memory. When I say "where does this run," the answer is always the same: the client. So the interesting question is not where the work happens — it is what data is canonical, and what is derived from it.

That distinction is the whole post.


One tree is the truth. Everything else is computed from it.

Start with the data, before any of the words.

Here is a small JSON object. We will follow it the whole way through.

{
  "name": "Ada",
  "age": 36,
  "address": {
    "city": "London"
  }
}

Inside the app, that JSON does not exist as JSON.

It exists as a list of nodes. One node per row. Here is the shape of a node:

interface TreeItem {
  id: string          // a stable handle — never appears in the JSON
  key: string
  type: 'string' | 'number' | 'boolean' | 'object'
  value: string | boolean
  children: TreeItem[] // used when type === 'object'
}

So our example becomes four nodes:

TreeItem  id:"a1"  key:"name"     type:"string"  value:"Ada"
TreeItem  id:"a2"  key:"age"      type:"number"  value:"36"
TreeItem  id:"a3"  key:"address"  type:"object"  children:[
  TreeItem  id:"a4"  key:"city"   type:"string"  value:"London"
]

This list — items: TreeItem[] — lives in a single store.

It is the source of truth.

The JSON on the right is not stored anywhere. It is recomputed from this list, fresh, every time the list changes. Hold onto that. It is the answer to the opening question, and we will earn it properly in a minute.

The data flow, top to bottom:

items: TreeItem[]   ← the one source of truth (in memory)
↓
itemsToJson(items)  ← runs on every change
↓
the JSON text you see on the right

Left is canonical. Right is derived. Nothing on the right is ever the truth.


The id you never see

Look back at that node shape. The first field is id. It is not part of your JSON. It will never be written to the output. So why is it there?

Because the app constantly has to answer one question:

Which node did the user just touch?

You type in the age field. Something has to find that exact node among all of them and update it. You drag the address row. Something has to know which node moved.

You might reach for the obvious handle: the position in the array. The age node is index 1.

This is bad.

The moment you drag age above name, it is index 0. Its identity changed out from under you, just because it moved.

So maybe use the key. The node is "age", after all.

This is also bad.

The key is editable — the user can rename it mid-edit. And nothing stops two rows from both being called "age". An identity that can be renamed or duplicated is not an identity.

So the fix is a separate, stable id that is never shown and never reused for anything else:

export const createItem = (key: string, type: ItemType): TreeItem => ({
  id: crypto.randomUUID(),
  key,
  type,
  value: DEFAULT_VALUE_FOR_TYPE[type],
  children: [],
})

Every node gets a crypto.randomUUID() at birth. "a1", "a2" above are stand-ins for those.

That id does three jobs, all of them about tracking:

It is the React key, so React can tell rows apart across re-renders.

It is the drag identity, so dnd-kit knows which row you grabbed.

It is the lookup key, so an update can find the right node.

The id is the thread the whole app holds onto. The key and the value can change freely; the id never does.


An update, traced end to end

Let's change age from 36 to 37.

You type in the field. The input fires onChange. The row calls one store action:

updateValue(item.id, event.target.value)   // updateValue("a2", "37")

Notice what it passes: the id, not the position, not the key. The id we just set up. That is the handle in action.

Now the store has to find node "a2" and change it. The nodes are nested — address has a child — so finding one means walking the tree:

const findItem = (items: TreeItem[], id: string): TreeItem | undefined => {
  for (const item of items) {
    if (item.id === id) return item
    const found = findItem(item.children, id)   // recurse into children
    if (found) return found
  }
  return undefined
}

Depth-first. Check each node. If it is not a match, descend into its children. Return the first node whose id matches.

What it returns is not a copy. It is the live node, and the action mutates it in place:

updateValue: (id, rawValue) =>
  set((state) => {
    const item = findItem(state.items, id)
    if (item) item.value = rawValue
  }),

item.value = rawValue looks like it breaks React's "never mutate state" rule.

It does not, because the store runs every change through Immer. You mutate a draft. Immer watches what you touched and produces a new immutable tree with only those nodes replaced. The age node and its ancestors get fresh object references; everything else is reused untouched.

So the update flow is:

you type "37"
↓
onChange → updateValue("a2", "37")
↓
findItem walks the tree, returns node a2
↓
mutate the Immer draft: item.value = "37"
↓
Immer produces a new items tree
↓
the store notifies React, the JSON recomputes

Every store action follows this exact shape. Rename a key, change a type, remove a node — find by id, mutate the draft, let Immer rebuild. The id is always the entry point.

One detail that looks like a bug and is not

The age field holds the number 36. But look again at the node:

TreeItem  id:"a2"  key:"age"  type:"number"  value:"36"

value:"36". A string. With quotes. For a number.

This is on purpose.

You might think a number field should store a number. Bind a <input type="number"> to it and be done.

But a controlled number input fights you mid-typing. Try to type 51.5. The instant you have typed 51., the value is not yet a valid number, so it gets coerced back to 51, and the decimal you were about to type is gone.

So number rows store the user's literal edit text"51." and all — exactly as typed. It stays text the entire time it lives in the tree.

It becomes a real number in exactly one place: the boundary where JSON is generated.

const primitiveJsonValue = (type, value) => {
  if (type === 'number') {
    const parsed = Number(value)
    return Number.isFinite(parsed) ? parsed : 0   // "37" → 37 here, and only here
  }
  if (type === 'boolean') return value === true
  return typeof value === 'string' ? value : String(value)
}

So the tree carries text, and the JSON carries a real number. The coercion is a single line, at a single boundary. Everywhere else, "37" is just text, and your typing is never snapped back.


The JSON pane is computed, never stored

Now we can pay off the promise from the top.

The JSON on the right is not a second copy of your data that the app keeps in sync. Keeping two copies in sync is exactly the bug that makes these things break. There is only one copy — the tree — and the JSON is a pure function of it:

export const itemsToJson = (items: TreeItem[]): JsonObject => {
  const json = {}
  for (const item of items) {
    json[item.key] = isObjectType(item.type)
      ? itemsToJson(item.children)   // recurse for objects
      : primitiveJsonValue(item.type, item.value)
  }
  return json
}

Walk the list in order. Each node contributes one key: value pair. Objects recurse into their children. Then JSON.stringify(result, null, 2) turns it into the indented text you read.

The right pane component does only this:

const items = useTreeStore((state) => state.items)
const jsonText = useMemo(() => itemsToJsonText(items), [items])

It subscribes to items. When items changes — and after any update, Immer hands back a new itemsuseMemo reruns and the text is regenerated.

So the right side cannot drift from the left. It is not synced to the left. It is the left, run through a function.

That is also why expand/collapse never touches the JSON. Which rows are open is view state, kept in a separate collapsedIds map, deliberately outside the tree. itemsToJson never looks at it. You can collapse address to tidy the view, and the output is byte-for-byte identical. Drawing state and data state never cross.


How a row renders itself

The tree on screen has the same recursive shape as the data, because the component renders itself.

TreeItemRow draws one node: its drag handle, its key input, its type dropdown, and its value editor. Then, if the node is an object, it renders a TreeItemRow for each child — which renders its children, and so on, as deep as the data goes.

{isObject && isExpanded && (
  <ul className="tree-list">
    {item.children.map((child) => (
      <TreeItemRow key={child.id} item={child} parentId={item.id} />
    ))}
  </ul>
)}

Two things ride along in that one line, and both matter later.

key={child.id} — the id again, now as React's reconciliation key. It is how React keeps each DOM row matched to its node across every re-render and reorder.

parentId={item.id} — every row is told who its parent is. A root row gets parentId={null}. This breadcrumb is what makes drag-and-drop safe, which is the next section.

Each row reaches into the store for only the slices it needs:

const updateKey = useTreeStore((state) => state.updateKey)
const removeItem = useTreeStore((state) => state.removeItem)
const isCollapsed = useTreeStore((state) => Boolean(state.collapsedIds[item.id]))

It does not receive a giant prop bundle from its parent and it does not hold the whole tree. It takes its one item, subscribes to the actions and the sliver of state it uses, and renders. The recursion gives you the shape; the store gives every row direct access to the truth.

App
↓
JsonTreeEditor (left)        JsonCodeView (right)
↓                            ↓
TreeItemRow (name)           itemsToJson(items)
TreeItemRow (age)            ↓
TreeItemRow (address)        the JSON text
  └ TreeItemRow (city)

Drag and drop: dnd-kit moves nothing

Here is the part that surprises people.

You drag the address row to a new spot. You might think dnd-kit picked up the node and moved it in your data.

It did not. dnd-kit does not touch your state at all.

All it does is watch the pointer and, when you let go, hand you two ids: the row you picked up, and the row you dropped it over.

const handleDragEnd = (event: DragEndEvent) => {
  const { active, over } = event
  if (!over || active.id === over.id) return
  // ...
}

active.id is the row you grabbed. over.id is the row you released onto. That is the entire gift from dnd-kit. Moving the node in your own state is your job — and because the truth is one tree keyed by id, you already have everything you need to do it.

But first, a guardrail. Remember the parentId breadcrumb every row was given? dnd-kit carries it in the drag payload, so when a drag ends, you can read the parent of both rows:

const activeParentId = (active.data.current?.parentId ?? null)
const overParentId = (over.data.current?.parentId ?? null)
if (activeParentId !== overParentId) return     // different parents → ignore

If you try to drag city (inside address) out to sit next to name (at the root), their parents differ, and the drag is dropped. Reordering is allowed only among true siblings — rows that share a parent. That keeps the operation a simple reshuffle of one list, never a re-parenting that would have to splice a node out of one place and into another.

When the parents match, the move is one store action:

reorderSiblings: (parentId, activeId, overId) =>
  set((state) => {
    const list = childListFor(state.items, parentId)   // the sibling array
    const from = list.findIndex((item) => item.id === activeId)
    const to = list.findIndex((item) => item.id === overId)
    const nothingToMove = from === -1 || to === -1 || from === to
    if (nothingToMove) return
    const reordered = arrayMove(list, from, to)
    list.splice(0, list.length, ...reordered)          // write order back into the draft
  }),

Find the right sibling list — the root list if parentId is null, otherwise that object's children. Find where the dragged node is and where it landed, by id. arrayMove returns the list in the new order. Splice that order back into the Immer draft.

Immer rebuilds the tree. The JSON recomputes. The key order in the output now matches the new row order, because itemsToJson walks the list in exactly the order the list is in.

The full drag flow:

you drop the row
↓
dnd-kit: "active=a3 ended over=a1"   (ids only — no data changed)
↓
same parent? if not, stop
↓
reorderSiblings(parent, "a3", "a1")
↓
arrayMove on the sibling list, written into the Immer draft
↓
new items tree → JSON recomputes in the new order

dnd-kit ran the gesture. The id told you which node it was. Your store did the move. The same division of labor as every other update.


Import: untrusted text becomes a trusted tree

Last flow. You paste JSON, or pick a .json file. The text has to become a tree of nodes — with ids, with types — before the editor can show it.

You might think you can hand the parsed object straight to the components. You cannot. Parsed JSON has no ids, so nothing can be tracked or dragged. And it is untrusted — it might not even be an object.

So import runs through one careful door:

export const parseJsonToItems = (text: string): ParseResult => {
  let parsed: unknown
  try {
    parsed = JSON.parse(text)
  } catch (error: unknown) {
    return { ok: false, error: error instanceof Error ? error.message : 'Invalid JSON' }
  }
  const isJsonObject =
    parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
  if (!isJsonObject) {
    return { ok: false, error: 'Top level must be a JSON object.' }
  }
  return { ok: true, items: jsonToItems(parsed) }
}

Three gates, in order:

Parse it. If JSON.parse throws, return the error message — never let it crash the app.

Check the top level is a plain object. Not null, not an array. The editor renders key: value rows, and only an object has those.

Hand the clean object to jsonToItems, which builds the tree.

Note the return type: a ParseResult that is either { ok: true, items } or { ok: false, error }. Errors come back as values, not exceptions, so the import panel can show "Top level must be a JSON object." in red instead of blowing up.

Building the tree is the mirror image of generating the JSON. Walk the object's entries; turn each into a node:

export const jsonToItems = (json: JsonObject): TreeItem[] =>
  Object.entries(json).map(([key, value]) => itemFromEntry(key, value))

const itemFromEntry = (key: string, value: JsonValue): TreeItem => {
  const type = itemTypeForValue(value)
  const item = createItem(key, type)          // ← fresh id minted here
  if (isPlainObject(value)) {
    item.children = jsonToItems(value)         // recurse for nested objects
    return item
  }
  if (type === 'boolean') { item.value = value; return item }
  item.value = primitiveTextFor(value)         // numbers land as text, e.g. 36 → "36"
  return item
}

Three things worth catching here:

createItem mints a fresh crypto.randomUUID() for every node. The imported data is fully tracked and draggable the instant it loads — same id machinery as everything else.

Nested objects recurse, so an imported address rebuilds its city child to any depth.

A number like 36 is stored as the text "36", exactly the convention from earlier — so an imported number behaves identically to one you typed.

And the edges? null and arrays are not among the four supported types, so rather than throw, they are kept as readable text via JSON.stringify and shown as string rows. Import never crashes on a value it does not model.

Once parseJsonToItems returns ok, the import control hands the new tree to the store:

setItems: (items) =>
  set((state) => {
    state.items = items
    state.collapsedIds = {}   // a new document starts fully expanded
  }),

The truth is replaced wholesale. The JSON pane, being derived, redraws itself the next render. No syncing. The new tree simply is the new truth.

pasted text
↓
JSON.parse           (reject on syntax error)
↓
top-level object?    (reject arrays, null, primitives)
↓
jsonToItems          (mint ids, recurse, numbers → text)
↓
setItems             (replace the truth)
↓
JSON pane recomputes from the new tree

The tradeoff, honestly

One source of truth that everything derives from is simple, and it is why nothing drifts. But it is not free, and it is not always what you want.

The JSON regenerates on every keystroke. The whole tree is walked each time. For a human-scale document that is instant and you will never feel it. For a 10,000-node tree, recomputing all of it on every character would be the first thing to fix — you would memoize per-subtree, or debounce the text generation.

And the model is deliberately small. Four types: string, number, boolean, object. No arrays. No null as a first-class value. That keeps the node shape and every conversion trivial to reason about. If you needed real arrays and round-trip-perfect editing of every JSON value, the tree would need a richer type, and several of the clean one-liners above would grow.

So this design is the right answer for an editor you can hold in your head. It is the wrong answer for a million-node document or a faithful JSON-spec editor. Knowing which you are building is the actual decision.


The id tracks every node, no matter how it is renamed or moved.

Immer rebuilds the one tree on every change.

And the JSON is never stored — only ever computed back from the truth.