Reactのchildrenで、propsのバケツリレーを減らす方法

​​React​children,memo,props,TypeScript

使わない props を受け取って下へ渡すだけのコンポーネントは、中身を children で受け取る入れ物に変えると、バケツリレーから外せます。データを持つコンポーネントが、使うコンポーネントを直接書けるようになるためです。

筆者は検証環境で、同じ画面をバケツリレーの書き方と children の書き方で作りました。そして、末端のコンポーネントに props を1つ足したときに直す場所の数と、入れ物の state が変わったときの描画回数を比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.56.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、Playwright で操作した Chromium で動かしました。StrictMode では包んでいません。

バケツリレーになっている例

ログイン中のユーザー(user)を持つ App があり、その名前を UserMenu が表示します。間には、画面の枠を作る Layout と、横の欄を作る Sidebar があります。Layout は、メニューを開いているかどうか(menuOpen)を自分の state として持っています。

各コンポーネントの先頭にある countRender は、検証用に、関数が呼ばれた回数(描画回数)を数える関数です。次のコードは、検証に使った Drilling.tsx の全体です。

tsx
// バケツリレーの書き方:user を Layout → Sidebar → UserMenu と順に渡す
import { useState } from 'react';
import { countRender } from './renders';

type User = { name: string };

export function DrillingApp() {
  countRender('App');
  const [user, setUser] = useState<User>({ name: 'たま' });
  return (
    <>
      <button id="rename" onClick={() => setUser({ name: user.name === 'たま' ? 'みけ' : 'たま' })}>
        名前を変える
      </button>
      <Layout user={user} />
    </>
  );
}

function Layout({ user }: { user: User }) {
  countRender('Layout');
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div className="layout">
      <button id="menu" onClick={() => setMenuOpen(!menuOpen)}>
        {menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
      </button>
      {menuOpen && <nav>ホーム / 記事一覧</nav>}
      <Sidebar user={user} />
      <ArticleList />
    </div>
  );
}

function Sidebar({ user }: { user: User }) {
  countRender('Sidebar');
  return (
    <aside>
      <UserMenu user={user} />
    </aside>
  );
}

function UserMenu({ user }: { user: User }) {
  countRender('UserMenu');
  return <p className="user">{user.name} さん</p>;
}

function ArticleList() {
  countRender('ArticleList');
  return <p className="articles">記事が3件あります</p>;
}

Layout と Sidebar は user を使っていません。受け取って、下に渡しているだけです。公式ドキュメントの Context の章の冒頭では、このように props をいくつものコンポーネントに通して渡していく状態を「prop drilling」と呼んでいます。

children で中身を受け取る形に書き直す

Layout と Sidebar を、中に何が入るかを知らない入れ物に変えます。UserMenu と ArticleList は App の中で作り、Layout と Sidebar には JSX として渡します。次のコードは Composition.tsx です。

tsx
// children の書き方:UserMenu と ArticleList を App で作り、Layout と Sidebar には中身として渡す
import { useState, type ReactNode } from 'react';
import { countRender } from './renders';

type User = { name: string };

export function CompositionApp() {
  countRender('App');
  const [user, setUser] = useState<User>({ name: 'たま' });
  return (
    <>
      <button id="rename" onClick={() => setUser({ name: user.name === 'たま' ? 'みけ' : 'たま' })}>
        名前を変える
      </button>
      <Layout
        sidebar={
          <Sidebar>
            <UserMenu user={user} />
          </Sidebar>
        }
      >
        <ArticleList />
      </Layout>
    </>
  );
}

function Layout({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
  countRender('Layout');
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div className="layout">
      <button id="menu" onClick={() => setMenuOpen(!menuOpen)}>
        {menuOpen ? 'メニューを閉じる' : 'メニューを開く'}
      </button>
      {menuOpen && <nav>ホーム / 記事一覧</nav>}
      {sidebar}
      {children}
    </div>
  );
}

function Sidebar({ children }: { children: ReactNode }) {
  countRender('Sidebar');
  return <aside>{children}</aside>;
}

ファイルの最後にある UserMenu と ArticleList は、前と同じものなので省きました。

タグの間に書いた <ArticleList /> は、Layout の children という props になります。公式ドキュメントの JSX を children として渡す説明では、children を持つコンポーネントを、親が好きな JSX で埋められる「穴」にたとえています。

穴が2つ以上ほしいときは、sidebar のように別の名前の props に JSX を渡します。公式ドキュメントの props の章にあるとおり、props にはオブジェクトや関数を含め、どんな JavaScript の値でも渡せるので、JSX も渡せます。型は、公式の TypeScript の説明にあるとおり ReactNode にしました。文字列や数値も受け取れる、広い型です。

検証環境で tsc を通し、Chromium で開いたところ、どちらの書き方でも「たま さん」「記事が3件あります」と表示されました。App の「名前を変える」を押すと、どちらも「みけ さん」に変わりました。

Layout と Sidebar の型から user が消えたので、この2つは user が何であるかを知らなくてよくなりました。これは、公式ドキュメントの Context を使う前に考えることで、2番目の方法として挙げられている書き方です。<Layout posts={posts} /> の代わりに <Layout><Posts posts={posts} /></Layout> と書けば、データを決めるコンポーネントと使うコンポーネントの間に挟まっている、データを使わずに渡すだけのコンポーネントが減る、と説明されています。

props を1つ足したときに直す場所

違いがはっきり出るのは、末端のコンポーネントに props を足すときです。UserMenu にログアウトのボタンを付け、押したときの処理を onLogout という必須の props で受け取ることにしました。

まず UserMenu だけを書き換えて、型チェックをしました。

text
src/Composition.tsx(18,14)
    error TS2741: Property 'onLogout' is missing in type '{ user: User; }' but required in type '{ user: User; onLogout: () => void; }'.
src/Drilling.tsx(39,8)
    error TS2741: Property 'onLogout' is missing in type '{ user: User; }' but required in type '{ user: User; onLogout: () => void; }'.

children の書き方では、エラーの場所(Composition.tsx の18行目)は App の中の <UserMenu user={user} /> です。App は user も setUser も持っているので、ここに onLogout を書けば終わります。

バケツリレーの書き方では、エラーの場所(Drilling.tsx の39行目)は Sidebar の中の <UserMenu user={user} /> です。Sidebar には onLogout に渡す関数がないので、Sidebar の props に足し、次は Layout の props に足し、最後に App で関数を渡すことになります。

型エラーがなくなるまで直したあと、元のファイルとの差分を取りました。書き換えたコンポーネントは次のとおりです。

書き方 書き換えたコンポーネント
バケツリレー App、Layout、Sidebar、UserMenu(4つ)
children App、UserMenu(2つ)

children の書き方での差分は、次の2か所でした(2つ目の、UserMenu の定義の変更は、バケツリレーの書き方でも同じです)。

diff
-            <UserMenu user={user} />
+            <UserMenu user={user} onLogout={() => setUser({ name: 'ゲスト' })} />
diff
-function UserMenu({ user }: { user: User }) {
+function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
   countRender('UserMenu');
-  return <p className="user">{user.name} さん</p>;
+  return <p className="user">{user.name} さん <button onClick={onLogout}>ログアウト</button></p>;

入れ物の state が変わっても、中身は描き直されない

children の書き方には、描画回数が減るという副産物もあります。Layout の「メニューを開く」を押して、Layout の state(menuOpen)を変えたときの描画回数を、Chromium で数えました。

「メニューを開く」を1回押した Layout Sidebar UserMenu ArticleList
バケツリレー 1回 1回 1回 1回
children 1回 0回 0回 0回

バケツリレーの書き方では、Sidebar 以下も Layout の中で作っているので、Layout と一緒に描き直されました。children の書き方では、Layout だけが描き直されました。

2つの書き方を並べて、メニューのボタン(開く/閉じる)を上と下で交互に2回ずつ、合わせて4回押したときの様子です。

上にバケツリレー、下にchildrenの書き方を並べ、メニューのボタンを上と下で交互に2回ずつ、合わせて4回押した画面。押すたびに、増えた描画回数がピンクになる。上はLayoutからArticleListまでの4つがそろって3回まで増え、下はLayoutだけが3回に増えて、残りは1回のまま
メニューのボタンを交互に4回押したときの描画回数の変わり方

Sidebar や UserMenu の要素は App が作ったものです。Layout の state が変わっても、Layout が受け取る sidebar と children は前と同じ要素のままなので、React はその中を描き直しません。公式リファレンスの memo を使う前に考えることでも、入れ物に JSX を children として渡すと、入れ物が自分の state を更新したときに中身を描き直さなくてよいと React に伝わる、と書かれています。Dan Abramov 氏も、自身のブログの記事「Before You memo()」で、同じ仕組みを memo の前に試す方法として紹介しています。

ポイント

children で減らせるのは、Layout や Sidebar のように、props を使わずに下へ渡すだけのコンポーネントです。入れ物が自分の state を変えたときに中身が描き直されないのは、その結果として付いてくる効果です。

children で変わらないこと

App の state が変わったときは、2つの書き方で差は出ませんでした。「名前を変える」を1回押すと、どちらも App、Layout、Sidebar、UserMenu、ArticleList が1回ずつ描き直されました。children の書き方でも、App が描き直されると <Sidebar> や <ArticleList /> の要素が作り直され、React は通常、親が描き直されると子も描き直すからです。

また、この書き方では中身を App で作っているので、Layout の中の menuOpen は、children として渡した中身からは使えません。

同じデータを、離れた場所にある多くのコンポーネントが使う場合は、Context が向いています。ただし公式ドキュメントは、props で渡すことと、children で渡すことの2つを先に考え、どちらもうまくいかないときに Context を検討する、という順番を勧めています。

children でバケツリレーから外せるのは、props を渡すだけのコンポーネント

Layout や Sidebar のように、受け取った props を使わずに下へ渡すだけのコンポーネントは、children(や sidebar のような JSX の props)で中身を受け取る形にすると、バケツリレーから外せます。検証では、末端に props を1つ足したときに書き換えるコンポーネントが4つから2つに減り、型エラーもデータを持つ App の中に出るようになりました。入れ物の state が変わっても中身は描き直されませんでしたが、データを持つ App の state が変わったときは、どちらの書き方でも全体が描き直されました。

​Posted by 86