Reactのchildrenで、propsのバケツリレーを減らす方法
使わない 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 の全体です。
// バケツリレーの書き方: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 です。
// 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 だけを書き換えて、型チェックをしました。
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 の定義の変更は、バケツリレーの書き方でも同じです)。
- <UserMenu user={user} />
+ <UserMenu user={user} onLogout={() => setUser({ name: 'ゲスト' })} />
-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回押したときの様子です。

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 が変わったときは、どちらの書き方でも全体が描き直されました。





