JSON Path Finder: From Tree Traversal to Path Generation#

Debugging a deeply nested API response recently, I needed to quickly locate the JSONPath of specific fields. Counting levels manually was painful, so I built a tool—click a node, get the path instantly.

What is JSONPath?#

JSONPath is a query language for JSON, similar to XPath for XML. For example:

const data = {
  users: [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 }
  ]
}

// JSONPath queries
$.users[0].name  // 'Alice'
$.users[*].age   // [25, 30]

$ represents the root, . accesses properties, [] accesses array indices. Simple and intuitive.

The Core Problem: Path Generation#

When a user clicks a tree node, we need the complete path from root to that node. This is essentially a path tracking problem.

Approach 1: Record Path During Construction#

Pass path information during recursive tree building:

interface TreeNode {
  key: string           // Current node name
  value: any           // Node value
  type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'
  path: string         // Complete JSONPath
  children?: TreeNode[]
}

function buildTree(obj: any, key: string = '$', path: string = '$'): TreeNode {
  // Handle null
  if (obj === null) {
    return { key, value: null, type: 'null', path }
  }
  
  // Handle array
  if (Array.isArray(obj)) {
    return {
      key,
      value: obj,
      type: 'array',
      path,
      children: obj.map((v, i) => 
        buildTree(v, `[${i}]`, `${path}[${i}]`)
      ),
    }
  }
  
  // Handle object
  if (typeof obj === 'object') {
    return {
      key,
      value: obj,
      type: 'object',
      path,
      children: Object.entries(obj).map(([k, v]) => {
        // Root's children use $.xxx, others use path.xxx
        const childPath = path === '$' ? `$.${k}` : `${path}.${k}`
        return buildTree(v, k, childPath)
      }),
    }
  }
  
  // Primitive types
  return { key, value: obj, type: typeof obj as any, path }
}

Key points:

  1. Array indices use []: $.users[0].name, not $.users.0.name
  2. Object properties use .: $.config.theme
  3. Root is $: This is the JSONPath standard

Approach 2: Trace Upward on Click#

Store parent references and trace upward when clicked:

interface TreeNode {
  key: string
  value: any
  parent: TreeNode | null
  children?: TreeNode[]
}

function getPath(node: TreeNode): string {
  const parts: string[] = []
  let current: TreeNode | null = node
  
  while (current) {
    parts.unshift(current.key)
    current = current.parent
  }
  
  // Build path
  return parts.reduce((acc, part, idx) => {
    if (idx === 0) return '$'
    if (part.startsWith('[')) return acc + part
    return acc + '.' + part
  }, '')
}

This is more flexible but requires traversal on every click. For frequent click scenarios, Approach 1 is better—compute once during construction, use directly afterward.

Tree Rendering: Recursive Component#

After getting the tree structure, render it as an interactive tree view:

function TreeNodeView({ node, selectedPath, onSelect, depth }: {
  node: TreeNode
  selectedPath: string | null
  onSelect: (path: string) => void
  depth: number
}) {
  const [expanded, setExpanded] = useState(depth < 3)  // Expand 3 levels by default
  const hasChildren = node.children && node.children.length > 0
  const isSelected = selectedPath === node.path
  
  // Type coloring
  const typeColors: Record<string, string> = {
    object: 'bg-blue-500/20 text-blue-400',
    array: 'bg-purple-500/20 text-purple-400',
    string: 'bg-green-500/20 text-green-400',
    number: 'bg-amber-500/20 text-amber-400',
    boolean: 'bg-cyan-500/20 text-cyan-400',
    null: 'bg-gray-500/20 text-gray-400',
  }
  
  return (
    <div>
      <div
        className={`flex items-center gap-1.5 py-1 px-2 rounded cursor-pointer ${
          isSelected ? 'bg-cyan-500/20 border border-cyan-500/50' : 'hover:bg-bg-tertiary'
        }`}
        style={{ paddingLeft: `${depth * 16 + 8}px` }}
        onClick={() => onSelect(node.path)}
      >
        {/* Expand/collapse button */}
        {hasChildren ? (
          <button onClick={(e) => { e.stopPropagation(); setExpanded(!expanded) }}>
            {expanded ? '▼' : '▶'}
          </button>
        ) : (
          <span className="w-4" />
        )}
        
        {/* Node name */}
        <span className="font-mono">{node.key}</span>
        
        {/* Type badge */}
        <span className={`text-xs px-1.5 py-0.5 rounded ${typeColors[node.type]}`}>
          {node.type}
        </span>
        
        {/* Show value for leaf nodes */}
        {!hasChildren && (
          <span className="text-xs truncate max-w-[200px]">
            {node.type === 'string' ? `"${node.value}"` : String(node.value)}
          </span>
        )}
      </div>
      
      {/* Recursively render children */}
      {hasChildren && expanded && (
        <div>
          {node.children!.map((child, i) => (
            <TreeNodeView
              key={i}
              node={child}
              selectedPath={selectedPath}
              onSelect={onSelect}
              depth={depth + 1}
            />
          ))}
        </div>
      )}
    </div>
  )
}

A few details:

  1. Indentation calculation: depth * 16 + 8px, 16px per level
  2. Default expansion level: depth < 3, avoid expanding too deep initially
  3. Event bubbling: The expand button’s click needs stopPropagation, otherwise it triggers selection
  4. Type coloring: Distinguish string/number/boolean/null at a glance

Performance: Handling Large JSON#

When JSON has deep nesting or long arrays, direct rendering causes lag.

1. Virtual Scrolling#

Only render visible nodes:

import { FixedSizeList } from 'react-window'

function VirtualTree({ nodes }: { nodes: TreeNode[] }) {
  // Flatten tree structure, preserving depth info
  const flatNodes = useMemo(() => {
    const result: (TreeNode & { depth: number })[] = []
    
    function flatten(node: TreeNode, depth: number) {
      result.push({ ...node, depth })
      if (node.children) {
        node.children.forEach(child => flatten(child, depth + 1))
      }
    }
    
    nodes.forEach(node => flatten(node, 0))
    return result
  }, [nodes])
  
  return (
    <FixedSizeList
      height={400}
      itemCount={flatNodes.length}
      itemSize={28}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <TreeNodeView node={flatNodes[index]} depth={flatNodes[index].depth} />
        </div>
      )}
    </FixedSizeList>
  )
}

2. Lazy Loading Children#

Render first level initially, load children on expand:

function TreeNodeView({ node }: { node: TreeNode }) {
  const [expanded, setExpanded] = useState(false)
  const [children, setChildren] = useState<TreeNode[] | null>(null)
  
  const handleExpand = useCallback(() => {
    if (!children && node.value && typeof node.value === 'object') {
      // Build subtree lazily
      const builtChildren = Object.entries(node.value).map(([k, v]) => 
        buildTree(v, k, `${node.path}.${k}`)
      )
      setChildren(builtChildren)
    }
    setExpanded(true)
  }, [node, children])
  
  // ...
}

3. Web Worker Parsing#

Parse large JSON in a Web Worker:

// worker.ts
self.onmessage = (e) => {
  try {
    const parsed = JSON.parse(e.data)
    const tree = buildTree(parsed)
    self.postMessage({ success: true, tree })
  } catch (err) {
    self.postMessage({ success: false, error: err.message })
  }
}

// main.tsx
const worker = new Worker('worker.ts')
worker.postMessage(jsonString)
worker.onmessage = (e) => {
  if (e.data.success) {
    setTree(e.data.tree)
  }
}

Edge Cases#

1. Empty Objects and Arrays#

const data = { obj: {}, arr: [] }

// Path generation
$.obj   // {} empty object
$.arr   // [] empty array

Empty objects and arrays have no children but are valid nodes. Display the count when rendering:

{node.type === 'object' && `{${node.children?.length || 0}}`}
{node.type === 'array' && `[${node.children?.length || 0}]`}

2. Special Key Names#

When keys contain special characters, JSONPath requires brackets:

const data = { 'user-name': 'Alice', '2fa': true }

// Standard notation
$['user-name']
$['2fa']

// Simplified notation (some parsers support)
$.user-name   // Might be parsed as subtraction
$.2fa         // Syntax error

Solution:

function needsBracket(key: string): boolean {
  // Starts with digit, contains special chars, or is JS keyword
  return /^[0-9]/.test(key) || /[^a-zA-Z0-9_]/.test(key)
}

function formatPath(base: string, key: string): string {
  if (needsBracket(key)) {
    return `${base}['${key}']`
  }
  return `${base}.${key}`
}

3. Circular References#

JSON doesn’t support circular references, but JavaScript objects might have them:

const obj = { a: 1 }
obj.self = obj

JSON.stringify(obj)  // TypeError: Converting circular structure to JSON

Detect circular references:

function hasCircular(obj: any, seen = new WeakSet()): boolean {
  if (obj && typeof obj === 'object') {
    if (seen.has(obj)) return true
    seen.add(obj)
    return Object.values(obj).some(v => hasCircular(v, seen))
  }
  return false
}

Check before buildTree to avoid infinite recursion.

The Result#

Based on these ideas, I built: JSON Path Finder

Features:

  • Paste JSON to auto-build tree view
  • Click nodes to generate JSONPath
  • Type coloring for distinction
  • Supports nested objects and arrays
  • One-click path copy

The code isn’t massive, but handling tree traversal, path generation, and rendering optimization well makes a big difference in user experience.


Related: JSON Formatter | JSON Diff