ReactでJSONを折りたたんで見やすく表示する方法

​​React​JSON,Playwright,TypeScript,useState

入れ子の深い JSON は、オブジェクトと配列のところで開いたり閉じたりできるようにすると、見たい部分だけを追えます。React では、値の種類を見て、オブジェクトと配列なら中身に対して同じコンポーネントを呼び直すと、どんな深さの JSON でも描けます。

ここでは、ライブラリを使わずに JsonViewer という部品を作りました。開いている場所は、ルートからのパス(キーと配列の番号の並び)を文字列にして、1つの Set で持ちます。パスの文字列の作り方を間違えると別の場所が一緒に開いてしまうこと、「すべて開く」「すべて閉じる」の作り方、JSON.parse で読めなかったときと大きな整数の扱いも、検証環境で確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、App を StrictMode で包んで開発用にビルドし、Playwright で Chromium を操作しました。スクリーンリーダーでの読み上げ、タッチ操作、Firefox と Safari、数万行のような大きな JSON の表示速度は試していません。

JSON の表示の上に「すべて開く」「すべて閉じる」のボタンがあり、下に「クリック n回目/5回」と「開いている:n個」の表示がある。tags と owner を順にクリックすると、それぞれの中身が開く。「すべて閉じる」を押すと「{…} 5件」の1行だけになり、開いている数が 0 になる。「すべて開く」を押すと address まですべて開いて 4 になり、最後に address をクリックすると、そこだけが閉じて 3 になる
tags と owner を開き、「すべて閉じる」「すべて開く」を押してから、address だけを閉じる様子

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。表示が変わった瞬間だけ、その数字をピンクにしています。撮影のときは、入力欄と「表示する」ボタンを隠し、ボタンの見た目、フォント、文字の大きさも、記事の CSS から変えています。

JSON の値を6種類に分けて型にする

JSON.parse が返す値は、null、真偽値、数値、文字列、配列、オブジェクトのどれかです。これを TypeScript の型にすると、配列とオブジェクトの中身にまた同じ型が入る、入れ子の型になります。

ts
export type JsonValue =
  | null
  | boolean
  | number
  | string
  | JsonValue[]
  | { [key: string]: JsonValue };

表示のうえでは、中に値を持つもの(配列とオブジェクト)と、それ以外の値の2つに分けます。MDN の typeof の説明にあるとおり、typeof null も 'object' になるので、null を先に外してから判定します。

ts
function isContainer(value: JsonValue): value is Container {
  return typeof value === 'object' && value !== null;
}

JsonViewer の全体

部品は JsonViewer.tsx の1ファイルにまとめました。JsonNode が1つの値を描き、中身があればその1つ1つに対して JsonNode を呼びます。

tsx
import { useState } from 'react';

export type JsonValue =
  | null
  | boolean
  | number
  | string
  | JsonValue[]
  | { [key: string]: JsonValue };

type Container = JsonValue[] | { [key: string]: JsonValue };
type Path = (string | number)[];

// パスの配列を、そのまま JSON の文字列にして Set のキーにする
const pathId = (path: Path) => JSON.stringify(path);

function isContainer(value: JsonValue): value is Container {
  return typeof value === 'object' && value !== null;
}

function entriesOf(value: Container): [string | number, JsonValue][] {
  return Array.isArray(value) ? value.map((v, i) => [i, v]) : Object.entries(value);
}

// 深さが maxDepth より浅いところにある、配列とオブジェクトのパスを集める
function collectPaths(value: JsonValue, maxDepth: number, path: Path = [], out = new Set<string>()) {
  if (!isContainer(value) || path.length >= maxDepth) return out;
  out.add(pathId(path));
  for (const [key, child] of entriesOf(value)) collectPaths(child, maxDepth, [...path, key], out);
  return out;
}

type Props = { value: JsonValue; defaultDepth?: number };

export function JsonViewer({ value, defaultDepth = 1 }: Props) {
  const [open, setOpen] = useState(() => collectPaths(value, defaultDepth));

  const toggle = (id: string) =>
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <div className="json-viewer">
      <div className="json-toolbar">
        <button type="button" onClick={() => setOpen(collectPaths(value, Infinity))}>すべて開く</button>
        <button type="button" onClick={() => setOpen(new Set())}>すべて閉じる</button>
      </div>
      <JsonNode name={null} value={value} path={[]} open={open} onToggle={toggle} />
    </div>
  );
}

type NodeProps = {
  name: string | number | null;
  value: JsonValue;
  path: Path;
  open: Set<string>;
  onToggle: (id: string) => void;
};

function JsonNode({ name, value, path, open, onToggle }: NodeProps) {
  // オブジェクトのキーは "name": のように引用符付きで、配列の番号は 0: のように出す
  const label =
    name === null ? null : (
      <span className="json-key">{typeof name === 'number' ? name : JSON.stringify(name)}: </span>
    );

  if (!isContainer(value)) {
    return (
      <div className="json-row">
        {label}
        <Primitive value={value} />
      </div>
    );
  }

  const id = pathId(path);
  const isOpen = open.has(id);
  const isArray = Array.isArray(value);
  const entries = entriesOf(value);
  const [start, end] = isArray ? ['[', ']'] : ['{', '}'];

  return (
    <div className="json-row">
      <button type="button" className="json-toggle" aria-expanded={isOpen} onClick={() => onToggle(id)}>
        <span className="json-arrow" aria-hidden="true">{isOpen ? '▼' : '▶'}</span>
        {label}
        {isOpen ? start : `${start}…${end}`}
        {!isOpen && <span className="json-count"> {entries.length}件</span>}
      </button>
      {isOpen && (
        <>
          <div className="json-children">
            {entries.map(([key, child]) => (
              <JsonNode key={key} name={key} value={child} path={[...path, key]} open={open} onToggle={onToggle} />
            ))}
          </div>
          <div className="json-close">{end}</div>
        </>
      )}
    </div>
  );
}

function Primitive({ value }: { value: null | boolean | number | string }) {
  if (value === null) return <span className="json-null">null</span>;
  if (typeof value === 'string') return <span className="json-string">{JSON.stringify(value)}</span>;
  if (typeof value === 'number') return <span className="json-number">{String(value)}</span>;
  return <span className="json-boolean">{String(value)}</span>;
}

字下げと色は CSS で付けます。中身を padding-left で右へずらし、左に細い線を引くと、どの括弧の中かを目で追いやすくなります。

css
.json-viewer { font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; }
.json-toolbar { display: flex; gap: 8px; margin-bottom: 8px; }
.json-children { padding-left: 1.5em; border-left: 1px solid #d0e8ee; margin-left: 0.4em; }
.json-toggle { font: inherit; background: none; border: 0; padding: 0; cursor: pointer; text-align: left; color: inherit; }
.json-arrow { display: inline-block; width: 1.2em; color: #067a98; }
.json-close { padding-left: 1.2em; }
.json-row > .json-key:first-child { margin-left: 1.2em; }
.json-key { color: #1f2a30; font-weight: bold; }
.json-string { color: #067a98; }
.json-number { color: #1f2a30; }
.json-boolean, .json-null { color: #6b7780; }
.json-count { color: #6b7780; }

次のような JSON を渡すと、最初はいちばん外側のオブジェクトだけが開き、中の tags と owner は閉じた状態で表示されました。閉じているところには、括弧の種類と中身の数を出しています。

json
{"id":12345678901234567890,"name":"ねこ","tags":["しろ","くろ"],"owner":{"name":"Taro","address":{"city":"Tokyo","zip":null}},"active":true}
text
▼{
"id": 12345678901234567000
"name": "ねこ"
▶"tags": […] 2件
▶"owner": {…} 2件
"active": true
}

id の数字が、渡した JSON と違っています。これは表示の問題ではなく、JSON.parse の時点で起きています。理由は最後の節で説明します。

開いている場所は、パスの文字列を Set に入れて持つ

開いているかどうかは、JsonNode ごとの useState ではなく、JsonViewer の1つの Set にまとめました。React の公式ドキュメントにある、state の保持とリセットの説明のとおり、React はコンポーネントを取り除くときに、その state も破棄します。親を閉じると子の JsonNode は取り除かれるので、ノードごとに持つと、親を開き直したときに中の開閉が最初に戻ります。Set にまとめると、検証環境でも owner を閉じて開き直したとき、中の address は開いたままでした。ノードごとに state を持つ書き方との比較と、toggle で new Set(prev) を作り直す理由は、以前の記事「Reactで、フォルダのような階層をツリーで表示する方法」に書いています。

この記事で違うのは、Set に入れる文字列です。ツリーの項目には自分で決めたパスがありますが、JSON にはキーの並びしかありません。そこで、ルートからのキーと配列の番号を並べた配列を、1つの文字列にします。

パスは「.」でつながず、JSON.stringify で文字列にする

パスを Set に入れるには、文字列にする必要があります。path.join('.') で owner.address のようにつなぐのが手軽ですが、JSON のキーには「.」も入ります。次の JSON では、"a.b" というキーと、a の中の b が、どちらも a.b になります。

json
{"a.b":{"x":1},"a":{"b":{"y":2}}}

pathId だけを path.join(".") に変えたものと比べました。a を開いてから "a.b" をクリックしたところ、記事のコードでは開いている数が 2 から 3 になり、"a.b" だけが開きました。path.join(".") のほうは 2 から 4 になり、クリックしていない a の中の b まで一緒に開きました。

JSON.stringify でパスの配列ごと文字列にすると、["a.b"] と ["a","b"] のように、キーの中の文字と区切りの文字がまざりません。キーに引用符が入っていても \" とエスケープされるので、a","b という1つのキーは ["a\",\"b"] になり、["a","b"] と区別できます。

最初に開く深さと、すべて開く・すべて閉じるボタン

collectPaths は、JSON をたどって、配列とオブジェクトのパスを集める関数です。深さの上限を渡すと、そこより浅いものだけを集めます。useState の初期値には collectPaths(value, defaultDepth) を渡し、最初は defaultDepth 段目まで開くようにしました。defaultDepth が 1 なら、いちばん外側だけが開きます。

「すべて開く」は、上限を Infinity にして集めた Set に置き換えます。「すべて閉じる」は、空の Set に置き換えます。検証環境では、「すべて開く」で開いている数が 4 になり(外側、tags、owner、address)、「すべて閉じる」で 0 になって、▶{…} 5件 の1行だけが残りました。

useState に関数を渡しているのは、初期値を作るのに JSON をたどるからです。公式リファレンスにある、初期 state が再生成されることを防ぐ方法にあるとおり、関数を渡すと、初期値を作る処理は最初のレンダーでしか呼ばれません。

文字列は JSON.stringify で引用符を付けて出す

文字列の値は、JSON.stringify を通してから表示しています。こうすると、前後に引用符が付き、中の改行や引用符も JSON と同じ書き方で見えます。改行と引用符を含む文字列と、空のオブジェクト・空の配列を表示したところ、次のようになりました。

text
▼{
"memo": "1行目\n\"引用\""
▶"empty": {…} 0件
▶"list": […] 0件
}

文字列をそのまま出すと、"true" という文字列と true という真偽値、"1" と 1 が同じ見た目になります。引用符を付けておくと、型の違いが見た目で分かります。

開閉のボタンに aria-expanded を付ける

開閉の操作は <button> で受けています。WAI-ARIA の Authoring Practices にある、表示と非表示を切り替えるパターンでは、中身を見せたり隠したりする要素はボタンにし、中身が見えているときは aria-expanded を true にする、とされています。▶ と ▼ の記号は見た目のためのものなので、aria-hidden="true" で読み上げの対象から外しました。

<button> なので、キーボードでも開閉できます。検証環境で owner のボタンにフォーカスを当て、Enter キーを押すと aria-expanded が true に、続けて Space キーを押すと false になりました。

入力された文字列を JSON.parse で読み込む

画面には、JSON を貼り付ける入力欄と「表示する」ボタンを置きました。JSON.parse は、JSON として正しくない文字列を受け取ると例外を投げます(MDN の JSON.parse() の説明)。そのため try / catch で囲み、失敗したらメッセージを出します。

tsx
import { useState } from 'react';
import { JsonViewer, type JsonValue } from './JsonViewer';

const SAMPLE = `{"id":12345678901234567890,"name":"ねこ","tags":["しろ","くろ"],"owner":{"name":"Taro","address":{"city":"Tokyo","zip":null}},"active":true}`;

export function App() {
  const [text, setText] = useState(SAMPLE);
  const [data, setData] = useState<{ value: JsonValue; version: number } | null>(null);
  const [error, setError] = useState<string | null>(null);

  const show = () => {
    try {
      const value = JSON.parse(text) as JsonValue;
      setData((prev) => ({ value, version: (prev?.version ?? 0) + 1 }));
      setError(null);
    } catch (e) {
      setError(e instanceof SyntaxError ? e.message : String(e));
    }
  };

  return (
    <main>
      <textarea aria-label="JSON" value={text} onChange={(e) => setText(e.target.value)} rows={5} />
      <button type="button" onClick={show}>表示する</button>
      {error && <p role="alert" className="json-error">JSON として読めません:{error}</p>}
      {data && <JsonViewer key={data.version} value={data.value} />}
    </main>
  );
}

検証環境の Chromium では、よくある書き間違いに対して次のメッセージが出ました。読めなかったときは data を変えないので、前に表示していた JSON はそのまま残ります。

入力 表示されたメッセージ
{"a":1,}(最後に「,」がある) Expected double-quoted property name in JSON at position 7 (line 1 column 8)
{'a':1}(キーを一重引用符で囲んだ) Expected property name or '}' in JSON at position 1 (line 1 column 2)
空の文字列 Unexpected end of JSON input

別の JSON を表示したら、開閉を最初に戻す

JsonViewer には、表示するたびに1つ増える version を key として渡しています。React の公式ドキュメントにある、key で state をリセットする方法のとおり、key が変わると state も新しく作られます。key の仕組みそのものは、以前の記事「keyを変えて、Reactコンポーネントの状態をリセットする方法」で扱っています。

ここでは、開閉の state で確かめました。key を外したものと比べ、1つ目の JSON で「すべて閉じる」を押してから、{"x":1,"y":[1,2]} を表示しました。key を渡したほうは、defaultDepth のとおり外側が開いた状態で表示されました。key を外したほうは、前の「すべて閉じる」の空の Set が残り、▶{…} 2件 と閉じたままでした。useState の初期値は最初のレンダーでしか使われないので、value が変わっても collectPaths は呼び直されません。

大きな整数は、JSON.parse の時点で丸められる

最初の表示で、12345678901234567890 が 12345678901234567000 になっていました。検証環境で JSON.parse('{"id":12345678901234567890}').id を調べても、同じ 12345678901234567000 でした。

JavaScript の数値で安全に表せる整数は、MDN の Number.MAX_SAFE_INTEGER の説明にあるとおり、253 – 1(9007199254740991)までです。これを超えると、正確に表せない整数が出てきます。そうした整数は、JSON.parse が数値にした時点で近い値に丸められ、表示する側では元の桁を取り戻せません。

注意

Number.MAX_SAFE_INTEGER を超える ID などを JSON の数値で受け取ると、表示も、それを使った処理も、丸められた値になることがあります。検証環境では、"12345678901234567890" のように文字列で書かれた値は、JSON.parse の後も同じ20桁のまま残りました。

​Posted by 86