JSON Viewer in React: Collapsible Tree for API Responses
Build a collapsible JSON tree viewer in React with Tailwind CSS. Render nested API responses, handle deep objects, and add copy-to-clipboard — no library needed.
Why You Probably Don't Need a Library for This
Honestly, most JSON viewer libraries are overkill for what you actually need. You install something that weighs 80kb, adds a dozen peer dependencies, and gives you styling you'll spend two hours fighting with. Then you discover it doesn't handle circular references in your specific way, or the collapse state isn't hoisted correctly, or it just looks wrong against your design system.
The good news: building a collapsible JSON tree in React takes maybe 60 lines of code. It renders correctly. You control every pixel. And when your designer asks for a dark mode variant or a specific hover color, you don't need to file a GitHub issue.
This guide builds a production-quality component from scratch using React and Tailwind CSS v4.0.2. We'll handle nested objects, arrays, primitive values, null, and undefined — the full range of what REST APIs actually return.
How the Recursive Tree Structure Works
The core idea is recursive rendering. A JSON value is either a primitive (string, number, boolean, null) or a container (object, array). If it's a container, you render its children — and each child might itself be a container. That's your recursion.
The trick is managing collapse state. Each node needs to track whether it's expanded or collapsed, independently of every other node. The simplest approach is useState local to each rendered node. This means collapsing a parent doesn't reset the expanded state of children — when you re-expand the parent, they'll be in whatever state they were left in.
You'll also want to handle edge cases: empty objects {}, empty arrays [], and very long string values that would overflow their container. We'll add truncate from Tailwind to handle the last one, with a toggle to show the full value on click.
Building the JSONNode Component
Here's the core component. It's intentionally kept flat — no abstracted subcomponents until you actually need them.
import { useState } from 'react';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
interface JSONNodeProps {
data: JSONValue;
keyName?: string;
depth?: number;
}
const INDENT = 16; // px per depth level
const primitiveColor = (val: JSONValue): string => {
if (val === null) return 'text-slate-400';
if (typeof val === 'boolean') return 'text-purple-400';
if (typeof val === 'number') return 'text-blue-400';
return 'text-green-400'; // string
};
export function JSONNode({ data, keyName, depth = 0 }: JSONNodeProps) {
const [isOpen, setIsOpen] = useState(depth < 2);
const isContainer =
typeof data === 'object' && data !== null;
const isArray = Array.isArray(data);
const entries = isContainer
? isArray
? (data as JSONValue[]).map((v, i) => [String(i), v] as [string, JSONValue])
: Object.entries(data as { [key: string]: JSONValue })
: [];
const bracketOpen = isArray ? '[' : '{';
const bracketClose = isArray ? ']' : '}';
const count = entries.length;
return (
<div style={{ paddingLeft: depth > 0 ? INDENT : 0 }}>
<span className="inline-flex items-center gap-1">
{keyName !== undefined && (
<span className="text-slate-300 font-medium">"{keyName}":\ </span>
)}
{isContainer ? (
<button
onClick={() => setIsOpen(o => !o)}
className="inline-flex items-center gap-0.5 text-slate-400 hover:text-slate-100 transition-colors"
>
<span className="text-slate-500 text-xs">{isOpen ? '▾' : '▸'}</span>
<span className="text-slate-200">{bracketOpen}</span>
{!isOpen && (
<span className="text-slate-500 text-xs mx-1">
{count} {count === 1 ? 'item' : 'items'}
</span>
)}
{!isOpen && <span className="text-slate-200">{bracketClose}</span>}
</button>
) : (
<span className={primitiveColor(data)}>
{data === null
? 'null'
: typeof data === 'string'
? `"${data}"`
: String(data)}
</span>
)}
</span>
{isContainer && isOpen && (
<div className="border-l border-slate-700 ml-1 mt-0.5">
{entries.map(([k, v]) => (
<JSONNode key={k} data={v} keyName={k} depth={depth + 1} />
))}
<div style={{ paddingLeft: INDENT }}>
<span className="text-slate-200">{bracketClose}</span>
</div>
</div>
)}
</div>
);
}A few intentional choices here. Nodes at depth 0 and 1 start expanded by default (depth < 2) — that's usually what you want for API responses where the top two levels of structure are always relevant. Deeper nesting starts collapsed to avoid wall-of-text syndrome. The 16px indent per level keeps things readable without eating too much horizontal space.
Wrapping It in a JSONViewer Shell
The JSONNode handles the data. You'll want a shell component that takes a raw string or parsed object, handles JSON parse errors gracefully, and gives you a toolbar with a copy button. This is the part users actually interact with.
import { useState } from 'react';
import { JSONNode } from './JSONNode';
interface JSONViewerProps {
value: string | object;
maxHeight?: string;
}
export function JSONViewer({ value, maxHeight = '480px' }: JSONViewerProps) {
const [copied, setCopied] = useState(false);
let parsed: unknown;
let parseError: string | null = null;
try {
parsed = typeof value === 'string' ? JSON.parse(value) : value;
} catch (e) {
parseError = (e as Error).message;
}
const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
const handleCopy = async () => {
await navigator.clipboard.writeText(raw);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="rounded-xl border border-slate-700 bg-slate-900 font-mono text-sm">
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-700">
<span className="text-slate-400 text-xs uppercase tracking-wide">JSON</span>
<button
onClick={handleCopy}
className="text-xs text-slate-400 hover:text-slate-100 transition-colors px-2 py-1 rounded hover:bg-slate-800"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<div
className="p-4 overflow-auto"
style={{ maxHeight }}
>
{parseError ? (
<span className="text-red-400">Parse error: {parseError}</span>
) : (
<JSONNode data={parsed as any} />
)}
</div>
</div>
);
}The maxHeight default of 480px keeps the component contained in a typical dashboard layout without clipping small responses. Override it to 'none' if you want the full response to render. The copy button uses a 2-second timeout to reset state — enough time to see the confirmation without it feeling sluggish.
Styling the Tree with Tailwind CSS
The color scheme above uses Tailwind's slate palette for the structure (brackets, keys, punctuation) and separate hues for value types. Strings are green-400, numbers are blue-400, booleans are purple-400, and null is slate-400. This matches the mental model most developers have from VS Code's dark theme.
One thing worth thinking about: if you're using the JSONViewer inside a cards stack component or a modal, you'll want to make sure the border radius and background don't create a double-border effect. Wrap it with overflow-hidden on the parent or pass className through as a prop.
If your app has a theme toggle, you'll need light-mode variants. Swap slate-900 to white, slate-700 borders to slate-200, and use slate-800/slate-900 for text instead of slate-300/slate-100. The color tokens for value types stay the same — green-600, blue-600, purple-600 read fine on white backgrounds.
Handling Large API Responses Without Freezing the UI
Ever thrown a 5000-line API response at a recursive React tree renderer and watched the tab go blank for three seconds? Yeah. Recursive rendering is fine for responses up to a few hundred nodes, but pagination endpoints, bulk exports, and analytics responses can get large fast.
The fix is virtual rendering for large arrays. If an array has more than 50 items, render a paginated slice instead of all entries. Add a "Show more" button that loads the next 50. This keeps the initial render under a millisecond even for gigantic payloads.
For deeply nested structures (depth > 8 or so), consider capping recursion and showing a "[Truncated]" placeholder. Most legitimate API responses don't go that deep — if something does, it's probably a circular reference or a misconfigured serializer, and you don't want to crash the browser trying to render it.
Adding Search and Highlight to the JSON Tree
Search within JSON is genuinely useful for debugging. The pattern: keep a search string in state at the JSONViewer level, pass it down as a prop, and highlight matches in key names and string values using a simple String.includes() check.
Don't try to highlight mid-string with JSX by splitting the string and wrapping the match in a <mark> — it works, but it complicates the component considerably. A simpler approach is to add a ring-1 ring-yellow-400 class to the whole value span when it contains the search term. Users see the match, you ship the feature in 20 minutes.
This pattern pairs well with animated tabs if you want to switch between a "Tree" view and a "Raw" view of the same response — the raw view is just a <pre> with the formatted JSON string, much faster to render when you need to scan text with Ctrl+F.
Integrating with Real API Responses
In practice you'll be passing API responses directly to this component. A pattern that works well with React Query or SWR:
import { useQuery } from '@tanstack/react-query';
import { JSONViewer } from '@/components/JSONViewer';
export function APIResponseDebugger({ endpoint }: { endpoint: string }) {
const { data, error, isLoading } = useQuery({
queryKey: ['debug', endpoint],
queryFn: () => fetch(endpoint).then(r => r.json()),
});
if (isLoading) return <div className="text-slate-400 p-4">Loading...</div>;
if (error) return <div className="text-red-400 p-4">Request failed</div>;
return <JSONViewer value={data} maxHeight="600px" />;
}Keep the JSONViewer as a pure display component — no fetching logic inside it. This keeps it reusable for mocked data in Storybook, for test fixtures, for pasting arbitrary JSON from clipboard, whatever you need. The fetching is your app's concern, not the viewer's.
If you're building an internal dev tool or admin dashboard and want this alongside something like a bento grid layout, the JSONViewer slots neatly into a grid cell as long as you give it an explicit height. Use h-full overflow-auto on the inner scroll container so it respects the grid cell boundaries.
FAQ
JSON.stringify throws on circular references, so you'll catch that in the parse step. If you're dealing with JavaScript objects that might be circular (not serialized JSON), pass them through a library like flatted first to serialize them safely, then feed the serialized string to your viewer. Don't try to handle circular refs in the recursive renderer itself — that way lies infinite loops.
Yes. The depth-based default (depth < 2) is just a starting point. You can change the condition to any logic you need: always expanded, always collapsed, expanded only for arrays, expanded only when the node has fewer than 5 children, whatever fits your use case. Pass an initialOpen prop and use that instead of the depth calculation.
Line numbers in a tree view don't map 1:1 to JSON lines, so they're usually not worth implementing in the tree renderer. If you need line numbers, show a 'Raw' tab with a syntax-highlighted <pre> block instead — that's where line numbers are actually meaningful. Libraries like react-syntax-highlighter handle that well with around 5 lines of code.
Because you're probably passing an inline object literal as the value prop — <JSONViewer value={{ foo: 'bar' }} />. That creates a new object reference on every render, which triggers a full re-render of the tree. Wrap the value in useMemo or move it outside the component. If you're passing data from React Query, the reference is stable between fetches, so it's fine.
Not with the pure recursive approach, and honestly that's an unusual requirement. If you need it, you'd need to flatten the tree structure first and build a custom layout rather than a true recursive renderer. Most of the time, the better solution is just setting the initial depth threshold so the relevant keys start visible.
Add role='tree' to the root element, role='treeitem' to each node, and aria-expanded on toggle buttons. The expand/collapse button should have an aria-label like 'Expand users object'. Keyboard navigation (arrow keys to navigate, Enter/Space to toggle) requires a bit more event handling but follows the standard ARIA tree pattern.