はじめに
こんにちは、フロントエンドエンジニアをしている大倉です。
この記事では、個人的に興味があったReactのProps Drilling問題について焦点を当て、この課題を解決するための効率的なコンポーネント設計について学んだことを紹介します。
本記事はHRBrain Advent Calendar 2023の6日目の記事です。
Prop Drilling問題とは?
Prop Drillingは、Reactのコンポーネント構造において、データ(Props)を親コンポーネントからその子孫コンポーネントへと階層を下っていく形で渡していくプロセスのことです。
Reactアプリケーションは多くのコンポーネントで構成され、これらはツリーのような階層構造を形成しています。Reactには、アプリケーションの状態管理を単純化し、データの流れを追跡しやすくするため「データは常に親から子へと一方向に流れる」という基本原則があります。そのため、複数の階層を持つコンポーネントツリーにおいて、データを直接下位のコンポーネントに渡す(間を飛ばす)ことは基本的にはできません。
Prop Drillingの問題は、各中間コンポーネントがデータを直接使用しなくても、それを下位のコンポーネントに渡すために必要になってしまうことです。
これは特に多くの階層を持つ大規模なアプリケーションでややこしくなりがちです。途中の中間コンポーネントは実際には、そのデータ(Props)を必要としないにも関わらず、データを「中継」する役割を担う必要があるため、コードの複雑化や再利用性の低下に繋がりやすくなります。
この問題を解決するためには、コンポーネント間のデータフローを最適化する必要があります。ここからは、より効率的で保守しやすいコンポーネント設計に向けた具体的な方法をProp Drillingの典型的な例と比較しながら見ていきましょう。
方法①:Context API
ReactのContext APIを使用することで、データをコンポーネントツリーの特定のレベルで提供し、必要なコンポーネントで必要なデータを利用することができます。
Prop Drillingの典型的な例
import React from "react";
type GrandChildProps = {
value: string;
};
const GrandChildComponent = ({ value }: GrandChildProps) => (
<div>{`Value is: ${value}`}</div>
);
type ChildProps = {
value: string;
};
const ChildComponent = ({ value }: ChildProps) => (
<GrandChildComponent value={value} />
);
const App = () => {
const value = "Hello World";
return <ChildComponent value={value} />;
};
ChildComponentは、value
をGrandChildComponentに渡す「中継」の役割を担っています。これだと、ChildComponentは特定のprops(value
)に依存してしまい、再利用性が低下してしまいます。
また、コンポーネントのネストがより深くなると、それだけ多くの中間コンポーネントを通過しなければならず、コードの複雑さが増します。
Context APIを使用した例
import React, { createContext, useContext } from 'react';
type MyContextType = {
value: string;
};
const MyContext = createContext<MyContextType>({ value: '' });
const App = () => {
const value = "Hello World";
return (
<MyContext.Provider value={{ value }}>
<ChildComponent />
</MyContext.Provider>
);
};
const GrandChildComponent = () => {
const { value } = useContext(MyContext);
return <div>{`Value is: ${value}`}</div>;
};
const ChildComponent = () => (
<GrandChildComponent />
);
この方法により、ChildComponentを介せずともGrandChildComponentにvalue
を渡すことができます。また、各コンポーネントは特定のPropsに依存せずに済むため、再利用性が向上します。
AppからGrandChildComponentへのデータの流れはContextを介して行われることで、よりクリーンでメンテナンスしやすいコード構造になりました。
公式ドキュメントの推奨事項
Context APIの使用を検討する際には注意が必要です。
例えば、Contextの値が頻繁に変更される場合、その変更はコンポーネントツリー全体に伝播し、パフォーマンスに悪影響を及ぼす可能性があります。Reactの公式ドキュメントを見ると、Context APIを使用する前に、まずはコンポーネント構造の見直しや再設計を検討することが推奨されています。
詳しくは、こちらのReactの公式ドキュメントをご確認ください。
方法②:コンポーネント合成 (Composition)
コンポーネント合成は、特に異なるレイアウトやスタイルを持つページやコンポーネントが必要な場合に有効です。
例えば、異なるセクションで共通のレイアウトコンポーネントを使用し、それぞれに異なるコンテンツを挿入したい場合において、コードの重複を避け、再利用性を高めることができます。
Prop Drillingの典型的な例
import React from "react";
type LayoutProps = {
title: string;
isDarkMode: boolean;
children: React.ReactNode;
}
const Header = ({ title, isDarkMode }: { title: string; isDarkMode: boolean }) => {
return (
<header style={{ backgroundColor: isDarkMode ? 'black' : 'white' }}>
<h1>{title}</h1>
</header>
);
};
const Footer = ({ isDarkMode }: { isDarkMode: boolean }) => {
return (
<footer style={{ backgroundColor: isDarkMode ? 'black' : 'white' }}>
<p>© 2023 My Website</p>
</footer>
);
};
const Layout = ({ title, isDarkMode, children }: LayoutProps) => {
return (
<div>
<Header title={title} isDarkMode={isDarkMode} />
<main>{children}</main>
<Footer isDarkMode={isDarkMode} />
</div>
);
};
const App = () => {
return (
<Layout title="My App" isDarkMode={true}>
<p>Welcome to my app!</p>
</Layout>
);
};
LayoutコンポーネントはHeaderとFooter、それぞれにプロパティ(title
、isDarkMode
)を渡しています。これだと以下のような問題があります。
- HeaderとFooterはLayout コンポーネントに依存しているため、他の場所で再利用する際に不便である。
-
title
やisDarkMode
が変更されるたびに、LayoutだけでなくHeaderとFooterも変更する必要がある。
コンポーネント合成 (Composition)を使用した例
import React from "react";
type LayoutProps = {
header: React.ReactNode;
footer: React.ReactNode;
children: React.ReactNode;
}
const Header = ({ children }: { children: React.ReactNode }) => {
return <header>{children}</header>;
};
const Footer = ({ children }: { children: React.ReactNode }) => {
return <footer>{children}</footer>;
};
const Layout = ({ header, footer, children }: LayoutProps) => {
return (
<div>
{header}
<main>{children}</main>
{footer}
</div>
);
};
const App = () => {
const isDarkMode = true;
return (
<Layout
header={<Header><h1 style={{ backgroundColor: isDarkMode ? 'black' : 'white' }}>My App</h1></Header>}
footer={<Footer><p style={{ backgroundColor: isDarkMode ? 'black' : 'white' }}>© 2023 My Website</p></Footer>}
>
<p>Welcome to my app!</p>
</Layout>
);
};
Layoutコンポーネントは、子コンポーネントを直接制御するのではなく、それらを描画するための「場所」を提供するようにします。これにより、コンポーネント間の結合が緩和され、汎用的なレイアウトコンポーネントになりました。
LayoutコンポーネントがHeaderとFooterをReactNode
として受け取ることで、以下のような利点があります。
- HeaderとFooterはLayoutから独立しているため、他のコンポーネント内で自由に再利用できる。
- Layoutコンポーネントは、任意のHeaderやFooterを受け入れることができるため、異なるスタイルや内容を持つHeaderやFooterを柔軟に取り入れることができる。
方法③:高階コンポーネント(HOC)
高階コンポーネント(HOC)は、コンポーネントに対して機能を追加するための関数で、データを直接子コンポーネントに渡す代わりに、必要なデータを持った新しいコンポーネントを生成します。
特に異なるコンポーネント間で共通の機能やロジックを再利用する際に有効です。例えば、認証、データフェッチ、エラーハンドリングなどの共通のロジックを高階コンポーネント(HOC)でラップすることで、これらの機能を必要とする複数のコンポーネントに適用することができます。
ここでは、認証機能を例にして見ていきます。
Prop Drillingの典型的な例
import React from "react";
type User = {
name: string;
isAuthenticated: boolean;
}
const Profile = ({ user }: { user: User }) => {
if (!user.isAuthenticated) {
return <p>Please log in.</p>;
}
return <div>Welcome, {user.name}!</div>;
};
const Navbar = ({ user }: { user: User }) => {
return (
<nav>
<Profile user={user} />
</nav>
);
};
const App = () => {
const user = {
name: "John Doe",
isAuthenticated: true
};
return <Navbar user={user} />;
};
高階コンポーネント(HOC)を使用した例
import React from "react";
type User = {
name: string;
isAuthenticated: boolean;
}
type WithAuthenticationProps = {
user: User;
}
// HOC: 認証状態に基づいてコンポーネントをレンダリングする
const withAuthentication = <P extends object>(
Component: React.ComponentType<P & WithAuthenticationProps>
) => {
return (props: P) => {
const user: User = {
name: "John Doe",
isAuthenticated: true, // 認証状態を設定
};
if (!user.isAuthenticated) {
return <p>Please log in.</p>;
}
return <Component {...(props as P)} user={user} />;
};
};
const Profile = withAuthentication(({ user }: WithAuthenticationProps) => {
return <div>Welcome, {user.name}!</div>;
});
const Navbar = () => {
return (
<nav>
<Profile />
</nav>
);
};
const App = () => {
return <Navbar />;
};
この例では、withAuthentication
がユーザー情報を管理し、認証されたユーザーに対してのみProfileコンポーネントをレンダリングしています。これにより、Appコンポーネントは、直接ユーザー情報を子コンポーネントに渡す必要がなくなりました。
ユーザー情報の管理と伝達がHOC内部で行われるため、各コンポーネントはそのデータの管理から解放されます。
このアプローチは、認証ロジックの重複を避け、コンポーネントの再利用性を高めます。また、アプリケーションの認証ロジックを一箇所に集中させることで、保守性と拡張性が向上します。
さいごに
本記事では、Props Drilling問題を解消する手段としてContext API、コンポーネント合成、高階コンポーネント(HOC)を紹介しましたが、これらの手法はそれぞれに長所と短所があり、プロジェクトの特定のニーズや構造によって最適な選択が異なるため注意が必要です。
また、ここまでProps Drillingの問題点ばかり見てきましたが、実際Props Drilling自体が必ずしも悪いというわけではありません。むしろ小規模なプロジェクトやシンプルなコンポーネント構造では、Props Drillingは直感的で簡単な方法となり得ます。なので、いきなりContext APIや他の複雑な手法を導入するよりも、プロジェクトの成長に応じて段階的にリファクタリングを行っていく方がいいかなと思います。
参考
PR
株式会社HRBrainでは新しいメンバーを募集しています。