From JSON.parse to Tree View: Building an Online JSON Formatter
From JSON.parse to Tree View: Building an Online JSON Formatter#
Dealing with minified JSON from APIs is a pain. I’ve tried several online tools, but they’re either bloated with ads or lack basic features. So I built my own. Here’s how it works.
The Core: Two Lines of Code#
const parsed = JSON.parse(input)
const formatted = JSON.stringify(parsed, null, 2)
The third argument of JSON.stringify is the indent size. Pass 2 for 2-space indent, 4 for 4-space, 0 for minified.
But building a usable tool requires more than that.
Error Position: From Character to Line/Column#
When JSON.parse fails, it throws:
Unexpected token } in JSON at position 45
position 45 means nothing to users. They need “line 3, column 12”.
The conversion is straightforward:
function getLineAndColumn(input: string, position: number) {
const lines = input.substring(0, position).split('\n')
const line = lines.length
const column = lines[lines.length - 1].length + 1
return { line, column }
}
Take the substring before the error position, split by newlines. Line number = array length. Column number = last line’s length.
Now the error message becomes:
JSON parse error: Unexpected token } (line 3, column 12)
Users can locate the problem instantly.
Tree View with Recursion#
Formatted JSON is readable, but deep nesting is still hard to follow. A tree view shows structure better.
The core is a recursive component:
function TreeNode({ data, name, level }: Props) {
const [expanded, setExpanded] = useState(true)
const isObject = data !== null && typeof data === 'object'
const isArray = Array.isArray(data)
if (!isObject) {
// Primitive type: show value directly
return (
<div style={{ marginLeft: level * 16 }}>
<span className="key">{name}:</span>
<span className={getTypeColor(data)}>
{typeof data === 'string' ? `"${data}"` : String(data)}
</span>
</div>
)
}
// Object/Array: render children recursively
const keys = Object.keys(data)
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? '▼' : '▶'} {name}
{isArray ? `[${keys.length}]` : `{${keys.length}}`}
</button>
{expanded && keys.map(key => (
<TreeNode
key={key}
data={isArray ? data[+key] : data[key]}
name={isArray ? `[${key}]` : key}
level={level + 1}
/>
))}
</div>
)
}
A few details:
- Type coloring: Strings in green, numbers in cyan, booleans in purple
- Array notation:
[3]for array length,{5}for object key count - Indentation:
level * 16pxfor each nesting level
Performance: Handling Large Files#
When JSON reaches several MB, direct rendering causes lag. Here are some optimizations:
1. Virtual Scrolling#
Only render visible nodes with react-window:
import { FixedSizeList } from 'react-window'
function VirtualTree({ data }: { data: object }) {
const nodes = flattenTree(data) // Flatten tree structure
return (
<FixedSizeList
height={600}
itemCount={nodes.length}
itemSize={24}
>
{({ index, style }) => (
<div style={style}>
<TreeNode data={nodes[index]} />
</div>
)}
</FixedSizeList>
)
}
2. Debounced Parsing#
Don’t parse on every keystroke. Use debounce:
const debouncedParse = useMemo(
() => debounce((value: string) => {
try {
const parsed = JSON.parse(value)
setOutput(formatJson(parsed))
} catch (e) {
setError(e.message)
}
}, 300),
[]
)
3. Web Worker#
Move JSON parsing to a Web Worker to avoid UI blocking:
// worker.ts
self.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data)
self.postMessage({ success: true, data: parsed })
} catch (e) {
self.postMessage({ success: false, error: e.message })
}
}
// main.tsx
const worker = new Worker('worker.ts')
worker.postMessage(largeJson)
worker.onmessage = (e) => {
if (e.data.success) {
setOutput(formatJson(e.data.data))
}
}
Edge Cases I Encountered#
1. Circular References#
JSON.stringify throws on circular references:
const obj = { a: 1 }
obj.self = obj
JSON.stringify(obj) // TypeError: Converting circular structure to JSON
Detect them before stringifying:
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
}
2. Special Characters#
JSON strings need proper escaping:
const json = '{"text": "Line1\\nLine2"}' // \n is newline
JSON.parse(json) // Works
const json2 = '{"text": "Line1
Line2"}' // Actual newline breaks
Your editor component needs to handle this or warn users.
3. Large Integer Precision#
JavaScript’s Number.MAX_SAFE_INTEGER is 2^53 - 1. Larger integers lose precision:
const json = '{"id": 9007199254740993}'
const obj = JSON.parse(json)
console.log(obj.id) // 9007199254740992, precision lost
Use the reviver parameter to preserve precision:
function safeParse(json: string) {
return JSON.parse(json, (key, value) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
return String(value) // Convert to string
}
return value
})
}
The Result#
Based on these ideas, I built: JSON Formatter
Features:
- Format / Minify / Validate
- Error position with line/column
- Collapsible tree view
- Supports files up to 10MB
The implementation isn’t complex, but getting the details right takes effort. Hope this helps.
Related: JSON Diff | JSON to CSV