JSON Path Finder: From Tree Traversal to Path Generation
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:
- Array indices use
[]:$.users[0].name, not$.users.0.name - Object properties use
.:$.config.theme - 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:
- Indentation calculation:
depth * 16 + 8px, 16px per level - Default expansion level:
depth < 3, avoid expanding too deep initially - Event bubbling: The expand button’s click needs
stopPropagation, otherwise it triggers selection - 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