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:

  1. Type coloring: Strings in green, numbers in cyan, booleans in purple
  2. Array notation: [3] for array length, {5} for object key count
  3. Indentation: level * 16px for 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