JSON Tree Editor: Recursive Rendering and Two-Way Sync
JSON Tree Editor: Recursive Rendering and Two-Way Sync#
Working with complex JSON data recently, plain text editing wasn’t cutting it. I needed a visual tree editor where I could add, delete, and modify nodes directly. So I built one. Here’s the core approach.
Data Model for Tree Structure#
Converting JSON to a tree requires a unified node model:
interface TreeNode {
id: string // Unique identifier (React key + node lookup)
key: string // Key name
value: any // Value (primitive stored directly, object/array as reference)
type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'
children?: TreeNode[] // Child nodes (only for object/array)
isExpanded?: boolean // Collapse state
}
Why the id field?
React’s key can’t use key names (might duplicate) or values (objects lack unique IDs). A random string works fine for rendering identity.
JSON → Tree Recursive Parsing#
Core function:
function parseJSONToTree(obj: any, key: string = 'root'): TreeNode {
const id = Math.random().toString(36).substr(2, 9)
// null needs special handling (typeof null === 'object')
if (obj === null) {
return { id, key, value: null, type: 'null' }
}
// Object: recursively process each property
if (typeof obj === 'object' && !Array.isArray(obj)) {
return {
id,
key,
value: obj,
type: 'object',
isExpanded: true,
children: Object.entries(obj).map(([k, v]) => parseJSONToTree(v, k))
}
}
// Array: recursively process each element, key is [0], [1]...
if (Array.isArray(obj)) {
return {
id,
key,
value: obj,
type: 'array',
isExpanded: true,
children: obj.map((v, i) => parseJSONToTree(v, `[${i}]`))
}
}
// Primitive: return directly
return { id, key, value: obj, type: typeof obj as any }
}
Key points:
- null trap:
typeof null === 'object', must check first - Array keys: Use
[0],[1]format for clarity - Expand state: Default all expanded for easier editing
Tree → JSON Reverse Conversion#
After editing, export JSON with reverse conversion:
function treeToJSON(node: TreeNode): any {
if (node.type === 'null') return null
if (node.type === 'object') {
const result: any = {}
node.children?.forEach(child => {
result[child.key] = treeToJSON(child)
})
return result
}
if (node.type === 'array') {
return node.children?.map(child => treeToJSON(child)) || []
}
return node.value
}
Why not JSON.stringify?
The tree’s value field stores original references. Direct stringify would hit circular reference issues. Recursive rebuild gives precise output control.
Two-Way Sync: Tree Edit → JSON Text#
When users edit nodes in tree view, sync the JSON text on the left:
const editNode = (id: string, newValue: any) => {
// 1. Recursively find and update node
const updateNode = (node: TreeNode): TreeNode => {
if (node.id === id) {
const newType = newValue === null ? 'null' : typeof newValue as any
return { ...node, value: newValue, type: newType }
}
if (node.children) {
return { ...node, children: node.children.map(updateNode) }
}
return node
}
// 2. Update tree
const newTree = updateNode(tree)
setTree(newTree)
// 3. Generate JSON, update text area
const newJson = treeToJSON(newTree)
setJsonInput(JSON.stringify(newJson, null, 2))
}
Recursive search over Map because tree depth is usually limited (JSON nesting has practical limits), and recursive code is clearer.
Node Collapse/Expand#
Toggle needs recursive isExpanded update:
const toggleNode = (id: string) => {
const updateNode = (node: TreeNode): TreeNode => {
if (node.id === id) {
return { ...node, isExpanded: !node.isExpanded }
}
if (node.children) {
return { ...node, children: node.children.map(updateNode) }
}
return node
}
setTree(updateNode(tree))
}
Performance optimization:
For large trees (thousands of nodes), recursive traversal every time gets slow. Use a Map for node references:
// Build index on init
const nodeMap = new Map<string, TreeNode>()
function buildMap(node: TreeNode) {
nodeMap.set(node.id, node)
node.children?.forEach(buildMap)
}
// Direct reference lookup O(1)
const toggleNode = (id: string) => {
const node = nodeMap.get(id)
if (node) node.isExpanded = !node.isExpanded
setTree({ ...tree }) // Trigger re-render
}
But JSON trees usually aren’t huge. Recursive is fine.
Node Deletion Edge Cases#
Deleting nodes requires handling parent’s children array:
const deleteNode = (id: string) => {
const removeNode = (node: TreeNode): TreeNode | null => {
if (node.id === id) return null // Found target, null means delete
if (node.children) {
const filtered = node.children
.map(removeNode)
.filter((n): n is TreeNode => n !== null) // Filter out nulls
return { ...node, children: filtered }
}
return node
}
const newTree = removeNode(tree)
if (newTree) {
setTree(newTree)
setJsonInput(JSON.stringify(treeToJSON(newTree), null, 2))
}
}
Why check newTree for null?
If user deletes root node, the whole tree is gone. Either prevent deletion or reset to empty object {}.
Type Coloring and Visual Feedback#
Different types get different colors for instant recognition:
<span className={`text-sm ${
node.type === 'string' ? 'text-green-500' :
node.type === 'number' ? 'text-amber-500' :
node.type === 'boolean' ? 'text-purple-500' :
node.type === 'null' ? 'text-gray-500' :
'text-text-secondary'
}`}>
{node.type === 'string' ? `"${node.value}"` :
node.type === 'null' ? 'null' :
node.type === 'object' ? `{${node.children?.length || 0} items}` :
node.type === 'array' ? `[${node.children?.length || 0} items]` :
String(node.value)}
</span>
Objects and arrays show child count for quick structure overview.
Real-World Use Cases#
This JSON tree editor works well for:
- Config file editing: Visually modify complex configurations
- API data debugging: Quickly locate and edit nested fields
- Teaching demos: Intuitively show JSON structure
Try it: JSON Tree Editor
Related: JSON Formatter | JSON Diff