🔁

フラットノードに変換してReactで扱う設計入門【ネストしたデータはつらい?】

に公開

はじめに

Reactでツリー構造のUI(フォルダ構造、コメント欄、カテゴリ階層など)を扱っていると、次のような違和感を覚えたことはないでしょうか。

  • ネストが深くなるとデータ更新がつらい
  • 特定のノードを探すだけなのに処理が複雑
  • state更新が直感的に書けない
  • 再帰だらけで「これで合っているのか」不安になる
    多くの場合、その原因は 「ネストしたデータ構造をそのまま扱おうとしていること」 にあります。

ネストしたデータ構造とは何か

まずは、よくあるネスト構造の例を見てみましょう。

ts
type Category = {
  id: string
  name: string
  children?: Category[]
}

const categories: Category[] = [
  {
    id: "1",
    name: "Frontend",
    children: [
      {
        id: "2",
        name: "React",
        children: [
          { id: "3", name: "Next.js" }
        ]
      }
    ]
  }
]

一見すると直感的で、UIにもそのまま使えそうです。

実際、描画だけであれば問題ありません。

ネスト構造が「つらくなる」瞬間

1.特定のノードを更新したいとき

例えば、

  • id === "3" のノード名を変更したい
    この場合、どうしますか?
  • ループ
  • 再帰
  • children を辿る処理
    が必要になります。
ts
function updateCategory(
  nodes: Category[],
  targetId: string,
  newName: string
): Category[] {
  return nodes.map(node => {
    if (node.id === targetId) {
      return { ...node, name: newName }
    }

    if (node.children) {
      return {
        ...node,
        children: updateCategory(node.children, targetId, newName)
      }
    }

    return node
  })
}

例として、上記のように毎回、再帰処理を書くのは現実的でしょうか?

2. 状態管理との相性が悪い

Reactでは「stateはなるべくシンプルに保つ」ことが重要です。

しかしネスト構造の場合、

  • 深い階層まで immutability を保つ
  • 一部だけ更新する
    という操作が非常に煩雑になります。

これは Redux / Zustand / useReducer などでも同様です。

フラットノードという考え方

そこで登場するのが フラットノード構造 です。
フラットノードとは

  • 各ノードを 1階層の配列 で管理
  • 親子関係はparentIdで表現
ts
type FlatCategory = {
  id: string
  name: string
  parentId: string | null
}

データは次のようになります。

ts
const flatCategories: FlatCategory[] = [
  { id: "1", name: "Frontend", parentId: null },
  { id: "2", name: "React", parentId: "1" },
  { id: "3", name: "Next.js", parentId: "2" }
]

フラット構造のメリット

1. 特定ノードの取得が簡単

ts
const target = flatCategories.find(c => c.id === "3")

2. 更新処理が単純

ts
const updated = flatCategories.map(c =>
  c.id === "3" ? { ...c, name: "Next.js App Router" } : c
)

3. 状態管理と相性が良い

  • 正規化されたデータ
  • Reduxの思想とも一致
  • 再帰を「データ管理」から排除できる

では、ツリーUIはどう描画するのか?

ここで疑問が生まれます。
フラットにしたら、ツリー表示できないのでは?
答えは 「描画時だけ再帰を使う」 です。

フラットノードをツリーに変換する

まず、フラット構造をツリー構造に変換します。

ts
type TreeCategory = FlatCategory & {
  children: TreeCategory[]
}
ts
function buildTree(
  nodes: FlatCategory[],
  parentId: string | null = null
): TreeCategory[] {
  return nodes
    .filter(node => node.parentId === parentId)
    .map(node => ({
      ...node,
      children: buildTree(nodes, node.id)
    }))
}

これが 再帰関数の典型例です。

  • 終了条件:該当する子がいなくなる
  • 自己呼び出し:buildTree(nodes, node.id)

ReactでツリーUIを描画する

再帰コンポーネント

ts
type Props = {
  nodes: TreeCategory[]
}

const CategoryTree: React.FC<Props> = ({ nodes }) => {
  return (
    <ul>
      {nodes.map(node => (
        <li key={node.id}>
          {node.name}
          {node.children.length > 0 && (
            <CategoryTree nodes={node.children} />
          )}
        </li>
      ))}
    </ul>
  )
}

使用例

ts
const tree = buildTree(flatCategories)

<CategoryTree nodes={tree} />

なぜ「フラット管理 × 再帰描画」が強いのか

責務が明確になる

役割 内容
データ管理 フラット構造
関係性 parentId
UI描画 再帰
再帰が「局所化」される
  • 再帰は UI変換のためだけ
  • state更新ロジックには登場しない
  • バグの温床になりにくい

実務でよく使われる例

  • コメント欄(X / Reddit / Qiita)
  • フォルダツリー
  • 権限・ロール管理
  • メニュー構造
  • 組織図
    多くのバックエンドAPIも フラット構造で返却 します。
    理由は同じです。

まとめ

  • ネストしたデータは、更新・探索・状態管理がつらくなる
  • フラットノード構造は、Reactと非常に相性が良い
  • 再帰は データ管理ではなく描画や変換で使う
  • フラットで持ち、ツリーで描く」が基本設計

Discussion