📖

ネスト構造のJSONをReactで扱うときにハマったポイントと解決策

に公開

はじめに

ReactでAPIから取得したJSONを元にUIを構築していたとき、「データが表示されない」「ドラッグ&ドロップで順番が変わらない」などの不具合に悩まされました。

原因は、APIから返ってくるデータが「ネスト構造」になっている点を踏まえずに実装したこと。

今回は、私が実際にハマったポイントと、それをどうやって解決したかを備忘録として記します。

ネスト構造のJSONを扱う可能性のある方、そしてD&Dやリストのソートなどを導入している方には参考になるかもしれません。

背景

  • React + Rails API で作ったメモアプリ

  • フォルダ構造は「親 → 子」のツリー形式

  • API側ではas_tree_jsonによってネスト構造のJSONが返ってくる

ハマったポイント

useStateで管理しているfoldersは、初回レンダリング時にAPIレスポンスのデータを受け取ってセットされます。

しかし、このfoldersデータは以下のようなネスト構造になっているため、コンポーネント内で.map().filter()といった処理をする際にうまく動かないケースが生じます。

[
  {
    id: 1,
    name: "親",
    parent_id: null,
    position: 0,
    children: [
      {
        id: 2,
        name: "子A",
        parent_id: 1,
        position: 0
      },
      {
        id: 3,
        name: "子B",
        parent_id: 1,
        position: 1
      }
    ]
  }
]

結果、諸々の処理の噛み合わせが悪くなり、フォルダ一覧を表示させたいダッシュボードページにおいて、初回レンダリング時、保存されているはずの同じparent_idを持つフォルダたちが表示されない不具合が起きました。

解決策

useEffect内でネスト構造を以下のようにflatten(平坦化)してsetFoldersする必要があります。

[
  { id: 1, name: "親", parent_id: null, position: 0 },
  { id: 2, name: "子A", parent_id: 1, position: 0 },
  { id: 3, name: "子B", parent_id: 1, position: 1 }
]

flattenFolders関数の概観

function flattenFolders(tree: Folder[]): Folder[] {
  const result: Folder[] = [];

  function walk(node: Folder) {
    const { children, ...rest } = node;
    result.push(rest); 
    if (children && Array.isArray(children)) {
      children.forEach(walk); 
    }
  }

  tree.forEach(walk);
  return result;
}

flattenFolders関数の解読

function flattenFolders(tree: Folder[]): Folder[] {

tree にはネスト構造のフォルダ配列が渡される想定。

戻り値は平坦化されたフォルダ配列


const result: Folder[] = [];

最終的に返すための、「平坦化したフォルダたち」を入れる空箱。

ここにフラットな形でフォルダを追加していく。


function walk(node: Folder) {

walk再帰関数(自分自身を呼ぶ関数)。

walknodeという名前は、ツリー構造(ネスト構造)を再帰的にたどる処理でよく使われる「慣習的な名前」。

フォルダをひとつずつ辿っていく役割。

1つのフォルダを処理して、必要ならその子供も見にいく。


const { children, ...rest } = node;

これは「分割代入」という書き方。

node(フォルダ)のchildrenを取り出して、残りの情報(id, name, parent_idなど)を...restでまとめている。

rest は { id, name, parent_id, position, ... } みたいな形になる。


result.push(rest);

children を除いた情報を result に追加(この時点で「親フォルダだけ」が追加される)。


if (children && Array.isArray(children)) {
      children.forEach(walk);
    }

Array.isArray()は、引数が配列かどうかを判定する関数。

もしchildrenがあり、中に配列がある(子フォルダが入っている)なら、それぞれの子フォルダにもwalk()を使って再帰的に処理する。

→ これにより親→子→孫…すべての階層をフラットにたどれる。


tree.forEach(walk);

最初にもらったtree のトップレベルのフォルダをすべて walk() で処理開始。


return result;

最終的に、フラットになったフォルダ配列が返ってくる。

flattenFolders関数の使い方

useEffect(() => {
    fetch('http://localhost:3000/api/folders')
      .then((res) => res.json())
      .then((data) => {
        const flat = flattenFolders(data);
        setFolders(flat);
      })
      .catch((err) => {
        console.error('フォルダ取得に失敗しました', err);
      });
  }, []);

useEffect内にて、flattenFolders関数でdataをフラット構造にし、setFoldersで保存する。

おまけ:リアルなネスト構造図

flattenFolders関数の挙動を頭でイメージしやすくするために、実際のアプリでよくある「ごちゃついたリアルなネスト構造」を掲載しておきます(完全自分用)。

[
  {
    "id": 1,
    "name": "トップ",
    "parent_id": null,
    "position": 0,
    "children": [
      {
        "id": 3,
        "name": "企画資料",
        "parent_id": 1,
        "position": 0,
        "children": [
          {
            "id": 12,
            "name": "2024年企画",
            "parent_id": 3,
            "position": 0
          },
          {
            "id": 24,
            "name": "2025年企画",
            "parent_id": 3,
            "position": 1
          }
        ]
      },
      {
        "id": 9,
        "name": "デザイン",
        "parent_id": 1,
        "position": 1
      },
      {
        "id": 45,
        "name": "開発資料",
        "parent_id": 1,
        "position": 2,
        "children": [
          {
            "id": 46,
            "name": "フロントエンド",
            "parent_id": 45,
            "position": 0
          },
          {
            "id": 47,
            "name": "バックエンド",
            "parent_id": 45,
            "position": 1,
            "children": [
              {
                "id": 48,
                "name": "Railsメモ",
                "parent_id": 47,
                "position": 0
              }
            ]
          }
        ]
      }
    ]
  }
]

最後に

.map().filter()のメソッドが、一次元配列にしか有効でないなんて知らなかった。

Discussion