コンポーネント指向と「ロジック」「描画」の適切な分離 #ヌーラボブログリレー2025夏
この記事はヌーラボブログリレー2025夏の13日目として投稿しています
はじめに
こんにちは、foo_543674と申します。
フロントエンドは昔Reactをメインで触っていたのですが、しばらく離れており、最近業務でReactを使ったり、プライベートでSolidJSで開発するようになったので、改めてフロントエンドについて「昔学んだ知識が陳腐化していないか確認したい」という思いから、コンポーネント指向について学び直しを行いました。今回はそれをまとめたものになります。
内容としては、便宜上React用語で説明しますが、React、Vue、Angular、Svelte、SolidJSなど、宣言的UIフレームワーク全般に共通して適用できるコンポーネント指向という考え方に関するものです。
コンポーネント指向とは?
コンポーネント指向の基本
まず、コンポーネント指向とは、再利用可能なコンポーネントに分割して、それらを組み合わせてアプリケーションを構築するアプローチです。
この説明を聞いて、経験豊富な開発者であれば、「当たり前のことでは?」と思うかもしれません。設計において関心の分離なんて基本中の基本です。ではなぜ、大げさにピックアップされることが多いのでしょうか。
よく似た概念との比較
よく似た概念として「オブジェクト指向」があります。これは、クラスというモジュールでアプリケーションを分割していくものですが、結局は関心の分離という点でコンポーネントもクラスもそこまで違いはありません。
じゃあ本質的に何が違うのかというと、コンポーネント指向では、従来のオブジェクト指向的なステートの管理方法についてのカプセル化は行いません。コンポーネントはクラスと違って「ステートマシン」ではないのです。
オブジェクト指向に慣れた開発者が陥りがちな誤解
この違いを理解せず、オブジェクト指向に慣れた開発者は、コンポーネントでも「カプセル化」を重視してしまいがちです。
しかし、内部状態を隠蔽し、Propsを最小限に抑えようとするアプローチには重大な問題があります。
具体的にどんな問題があるの?というのは、以下の記事がわかりやすいと思います:
ワイ「何で子コンポーネントに状態を持たせたらあかんの?」 - Qiita
まとめると、主な問題点として、
- 外部制御不可能 - 親コンポーネントから状態を制御できない
- 手続き的UIになる - $refsなどを使った手続き的処理が発生する
- テスト困難 - 特定の状態に強制的に設定できない
- 再利用性の低下 - 様々な文脈での使用が制限される
※Reactが親コンポーネントから子コンポーネントを制御しやすい機能を用意すればいいだけでは?と思うかもしれませんが、Reactがこういうことをやりにくくしている理由は単純で、やるべきではないからです。Reactというのは、保守性の高いフロントエンドアプリケーションを開発するためのベストプラクティスを、開発者に半ば強制してきます。Reactでやりにくいことは、すなわちフロントエンド開発においてやるべきではないことであるものが多いです。
コンポーネント指向の本質:状態制御の公開
コンポーネント指向では、オブジェクト指向と正反対に、Propsによる状態制御の公開が重要です。
コンポーネントは「親から受け取った状態を映し出す単なるディスプレイ」として設計されるべきです。この透明性により以下が実現されます:
- 完全な制御可能性: 外部から任意の状態を容易に再現可能
- 宣言的UIになる: 「この状態の時はこう」という宣言的書き方で読みやすくなる
- テスタビリティ: Storybookによるカタログ化やビジュアル回帰テストが容易にできる
- 再利用性: 様々な文脈で柔軟に使用可能
これらを追求していった結果生まれたのがContainer&Presentationalパターンです。
Container&Presentationalパターンとは
簡単にいうと、コンポーネントを描画処理を記述するPresentationalコンポーネントと、ロジックを記述するContainerコンポーネントに分けようというアプローチです。このパターンは非常によく使っておりました。
私がReactをメインに触っていたのは2016年前後ですが、当時は結構このパターンは流行っていたような印象を受けます。ちょうどFunctional Componentが出てきたりして、「古い」とか「もういらない」って説も出てはいました。また、提唱者であるDan Abramov氏自身が2019年に「I don't suggest splitting your components like this anymore」と撤回しており、すでに廃れたパターンであるというのが最近の定説です。しかし、私は論拠を見る限りそうは思いませんでした。
意見を色々書く前にまず、このパターンがどういうものであるかを説明します。
Presentationalコンポーネントの役割
Presentationalコンポーネントは描画を担います。StateやContextを持たず、純粋にPropsに応じた描画処理と、ユーザー操作を親に打ち上げることだけを行います。その範囲は目に見える基本的なUI部品にとどまらず、レイアウトからUIのまとめ方・並び方なども含めます。状態はすべてPropsで公開され、ユーザー操作はイベントで親コンポーネントに打ち上げていきます。
1. 基本的なUI部品
ボタンとかチェックボックスとかのUIの最小単位となる部品です。実際のアプリケーション開発ではUIライブラリ(Material-UI、Ant Design、Chakra UIなど)を使うことが多いため、あまり積極的に0から作ることはありません。
const Button = ({ children, variant, onClick, disabled }) => (
<button
className={`btn btn-${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
const SearchInput = ({ value, onChange, placeholder }) => (
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
className="search-input"
/>
);
2. アニメーション機能
UIに動きをつけるコンポーネントです。中身については一切関与せず、childrenがどう動くかだけを書きます。
const FadeTransition = ({ children, isVisible, duration }) => (
<div
className="fade-transition"
style={{
opacity: isVisible ? 1 : 0,
transition: `opacity ${duration}ms ease-in-out`
}}
>
{children}
</div>
);
const SlidePanel = ({ children, direction, isOpen }) => (
<div
className={`slide-panel slide-${direction} ${isOpen ? 'open' : 'closed'}`}
>
{children}
</div>
);
3. レイアウト機能
UIの配置方法などを制御するコンポーネントです。こちらも中身については一切関与しません。どこに何をどういう間隔で配置するかといったレイアウトにのみ注力します。
const SidebarLayout = ({ sidebar, content, isMenuOpen }) => (
<div className="layout">
<aside className={`sidebar ${isMenuOpen ? 'open' : 'closed'}`}>
{sidebar}
</aside>
<main className="content">
{content}
</main>
</div>
);
4. UI部品をまとめて全体を構築する・並び方を管理する
レイアウトやアニメーション、部品を組み合わせ、アプリケーションのUIを構築します。
例えば登録フォームみたいな画面全体を表すコンポーネントから、プロフィールカードやラベル付き入力エリアなど、大小様々なコンポーネントがあります。実際のアプリケーション開発で最も多く作るのがこのコンポーネントだと思います。
const UserProfileCard = ({
user,
isLoading,
onEdit,
onDelete,
showActions
}) => (
<div className="user-profile-card">
{isLoading ? (
<LoadingSpinner />
) : (
<>
<Avatar src={user.avatar} alt={user.name} />
<UserInfo user={user} />
{showActions && (
<ActionButtons onEdit={onEdit} onDelete={onDelete} />
)}
</>
)}
</div>
);
Containerコンポーネントの役割
Containerコンポーネントの当初の目的は、Presentationalコンポーネントにロジックを注入することと言われてました。Presentationalコンポーネントに渡すステートを作り、それらがどの様に制御されるかを記述します。
// Container: ロジックを記述してPresentationalコンポーネントに渡す
const UserProfileContainer = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await api.getUser(userId);
setUser(userData);
setLoading(false);
} catch (err) {
setError(err);
setLoading(false);
}
};
fetchUser();
}, [userId]);
const handleEdit = () => {
// 編集ロジック
};
const handleDelete = async () => {
await api.deleteUser(userId);
// 削除後の処理
};
return (
<UserProfileCard
user={user}
loading={loading}
error={error}
onEdit={handleEdit}
onDelete={handleDelete}
showActions={true}
/>
);
};
Containerコンポーネントにはごちゃごちゃとした描画ロジックは書きません。理想的にはreturnで返すのは単一のPresentationalコンポーネントだけです。
ContainerコンポーネントはHooksの登場で不要になった?
Hooksとは
React 16.8で導入されたHooksは、関数コンポーネント内でstateやライフサイクルなどのReact機能を使えるようにする仕組みです。これによりclass componentを書かなくても複雑なロジックが実装できるようになりました。また、Hooks同士をまとめて再利用可能なロジック(カスタムHooks)を記述しやすくなりました。
// カスタムHooks使用例
const useUserData = (userId) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
const userData = await api.getUser(userId);
setUser(userData);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
return { user, loading, error };
};
const SomeComponent = ({ userId }) => {
const { user, loading, error } = useUserData(userId) // カスタムフックを呼び出すだけでロジックを利用できる
return (
...
)
}
最近だとReactQueryのような、より宣言的にAPI問い合わせなどが書けるものも登場しています。
また、それぞれ違いはあれど、VueであればComposition APIだったり、AngularであれなSignalsのように、各宣言的UIフレームワークが関数型的なアプローチが取り入れて以降は主流となっている機構です。
「Containerコンポーネント不要論」の登場
Hooksの登場により、ロジックがひとかたまりで再利用性高く記述できるようになりました。これによって、「ロジックを記述するContainerコンポーネントはもう不要では?」「Containerコンポーネントなんか書くよりHooksをフル活用したほうがいい」という議論が生まれました。
「Hooksをフル活用したほうがいい」という点は全面的に同意しますが、この議論には重要な誤解があると思います。
HooksとContainerは対立する概念ではなく、それぞれ異なる目的を持つ協調関係ということです。
HooksとContainerコンポーネントの協調による改善
// Hook: ロジックの整理とパッケージング
const useUserManagement = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const fetchUsers = async () => {
setLoading(true);
const data = await api.getUsers();
setUsers(data);
setLoading(false);
};
const deleteUser = async (id) => {
await api.deleteUser(id);
setUsers(users.filter(u => u.id !== id));
};
return { users, loading, fetchUsers, deleteUser };
};
// Container: HooksとPresentationalコンポーネントの結合
const UserManagementContainer = () => {
const { users, loading, deleteUser } = useUserManagement();
return (
<UserProfileCard
users={users}
loading={loading}
onDelete={deleteUser}
/>
);
};
上記サンプルコードは描画をPresentationalコンポーネント、ロジックの処理をカスタムHook、そして、そのロジックと描画の結合をContainerコンポーネントで行っています。つまり、Containerコンポーネントはロジックを直接書くところから、ロジックと描画の結合を行う場所に変わっただけです。Containerコンポーネントに本質的に求められているのは、ロジックと描画の結合とその依存関係の整理なんです。
Containerコンポーネントを使わずにPresentationalコンポーネントで直接ロジックと結合させたときの問題点
描画処理だけが欲しいのにロジックがついてくるため、再利用性とテスタビリティが下がります。例えば、ユーザーの一覧画面が欲しくなった時にUserProfileCardの「見た目」だけがほしいのに、中でAPIを呼び出していたら、「ユーザー一覧APIを使ったほうが効率的なのに個別にユーザー取得APIを呼び出す」といった非効率的なことをしなければいけません。Storybookでカタログ化する時も、UserProfileCardを使いたいコンポーネントは、いちいち中身を見に行ってどのエンドポイントを呼んでるのか確認し、スタブしなければいけなくなります。
だからこそ、ロジックと描画処理を結合させるレイヤーが必要になります。HooksとContainerは、それぞれ目的が違うんです。
- Hooks: ロジックの整理と再利用性を高めるための機能
- Container: 描画とロジックの結合・依存関係整理
pseudo state問題とエコシステムの進化
「じゃあPresentational&Containerパターンを導入しよう!」となり、Presentationalコンポーネントにステートを作らないことを徹底しようとすると、恐らく最初のうちは色々な問題が発生すると思います。
代表的なのが、UIに密接に関係するステートをどうするかという問題です。
pseudo state
例えば、ホバー中にハイライトされるボタンを作りたい時、状態によって見た目を変えたいので、isHovered
のようなUIに密接だがロジックとあまり関係ない状態までPropsで管理することになりました。基本的なUI部品ならいいかもしれません。しかし、それらをまとめて並び方を管理するコンポーネントもPresentationalコンポーネントなのでステートを書けません。従って、isHoveredがContainerまで打ち上がってきます。
// Container でこんなコードが...
const [isButtonHovered, setIsButtonHovered] = useState(false);
const [isCheckboxHovered, setIsCheckboxHovered] = useState(false);
const [isIconHovered, setIsIconHovered] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false);
return (
<UserProfileCard
isButtonHovered={isButtonHovered}
isCheckboxHovered={isCheckboxHovered}
isIconHovered={isIconHovered}
isInputFocused={isInputFocused}
onButtonMouseEnter={() => setIsButtonHovered(true)}
onButtonMouseLeave={() => setIsButtonHovered(false)}
onCheckboxMouseEnter={() => setIsCheckboxHovered(true)}
onCheckboxMouseLeave={() => setIsCheckboxHovered(false)}
onInputFocus={() => setIsInputFocused(true)}
onInputBlur={() => setIsInputFocused(false)}
// ... さらに続く
/>
);
リッチでUXを考慮したUIであればあるほど、この問題は頻発します。
この可読性の低下により、「Stateをカプセル化したい」「やっぱりPresentational&Containerパターンは机上の空論だ」という流れになりやすいです。
しかし現在では、この問題はほとんど解決されています。
1. CSS-in-JSの進化による制御
CSSには昔から、擬似クラス (pseudo-classes)というものがあります。しかし、以前はJSでCSSの制御をする機能があまりなく、主流ではありませんでした。今は個別のCSSクラスを細かく個別のコンポーネントに適用できる手法が豊富になったので、これをフル活用すればステートどころかPropsにもする必要ありません。
// styled-components やemotionによる解決
const StyledButton = styled.button`
background-color: #fff;
&:hover {
background-color: #f0f0f0;
}
&:focus {
outline: 2px solid #007acc;
}
&:active {
transform: scale(0.95);
}
`;
// CSS Modulesによる解決
const Button = ({ children, onClick }) => (
<button className={styles.button} onClick={onClick}>
{children}
</button>
);
CSS疑似クラスをフル活用することで以下が実現されます:
- 見た目に深く関わる余計なステートが減って読みやすい
- デザインのためのステートと、ロジックのためのステートが分離されるので、一貫性が向上する
- Propsで状態を渡したときと同様、コンポーネントが表示された瞬間からホバー状態などの描画にできるので、Storybookを用いたカタログ化やビジュアル回帰テストが書きやすい(Storybook Pseudo Statesというプラグインが便利)
- ブラウザの開発者ツールでも簡単にエミュレートできる
2. 内部ステートによるハイブリッドアプローチ
UIに密接に関わるのはpseudo stateだけではありません。例えば折りたたみ可能なセクションの場合、isOpenedといった状態があると思います。
// 本当は開閉アニメーションとかがあると思いますが、説明を単純にするために省きます。
const UserListSection = ({ isOpened, onClick }) => {
return (
<div>
<button onClick={onClick}>
{actualIsOpened ? <DownArrow> : <RightArrow>}
</button>
{isOpened && (
<div>
<h2>ユーザー管理</h2>
<ul>
<li>田中 太郎</li>
<li>鈴木 花子</li>
<li>山田 健太</li>
</ul>
</div>
)}
</div>
)
}
これもisOpenedのステートをPresentationalコンポーネントで記述できないとなると、Containerコンポーネントにロジックとはあまり関係ないステートが出てきてしまいます。ただし、こういう状態は例えば「開閉状態はCookieに保存して保持しておきたい」とか、「一括開閉ボタンを作りたい」といったロジックがあるケースもあります。こういう時と場合によってロジックになったりならなかったりするステートは、ハイブリッドアプローチで作ると良いと思います。UIライブラリはこのアプローチで作られてるのをよく見ます。
const UserListSection = ({ isOpened, onClick, initialIsOpened }) => {
const [isOpenedState, setIsOpenedState] = useState(initialIsOpened ?? true)
const actualIsOpened = isOpened ?? isOpenedState
return (
<div>
<button onClick={onClick}>
{actualIsOpened ? <DownArrow> : <RightArrow>}
</button>
{actualIsOpened && (
<div>
<h2>ユーザー管理</h2>
<ul>
<li>田中 太郎</li>
<li>鈴木 花子</li>
<li>山田 健太</li>
</ul>
</div>
)}
</div>
)
}
このやり方であれば、このステートにロジックがない時は、
const SomeForm = () => {
return (
<UserListSection />
)
}
この様に配置するだけでそのまま使えます。
ロジックで制御したい時は、
const SomeForm = ({ isUserSectionOpened, onUserSectionClick }) => {
return (
<UserListSection isOpened={isUserSectionOpened} onClick={onUserSectionClick}/>
)
}
const SomeContainer = () => {
const [isUserSectionOpened, setIsUserSectionOpened] = useCookie("userSectionOpened")
return (
<SomeForm isUserSectionOpened={isUserSectionOpened} onUserSectionClick={setIsUserSectionOpened}/>
)
}
この様に外部から制御もできます。
まとめ
技術制約が生んだ誤解
Container&Presentationalパターンは「古い」とか「不要」などと言われていますが、関心の分離と依存関係の整理といった面では現在でも有用だと思いますし、エコシステムなどの進化によって、よりシンプルに扱えるようになっています。
- 適切な関心の分離: ロジック、描画の明確な役割分担
- 再利用性の最大化: UI部品、アニメーション、レイアウト、ロジックの再利用
- テスタビリティ: 各層の独立したテスト容易性
- 保守性: 変更の影響範囲の限定
HooksとContainerは協調する関係
- Hooks: ロジックの記述と再利用
- Presentational: 描画の再利用(UI部品/アニメーション/レイアウト)
- Container: ロジックと描画の適切な結合
これらを組み合わせることで、堅牢で保守しやすいコンポーネント設計が実現できます。
宣言的UIと適切な関心分離による持続可能な開発
記事で詳しく解説したContainer&Presentationalパターンは、単なる古い設計手法ではなく、「描画」と「ロジック」を適切に分離することで、宣言的UIの真の価値を引き出すアプローチです。
宣言的UIは、頻繁な変更を強いられるフロントエンド開発において、読みやすさと保守性を大きく向上させます。関数型プログラミングの流れとも相まって、この恩恵はますます重要になっています。しかし、useEffectを多用する手続き的な書き方や、不適切な状態管理をしてしまうと、せっかくの宣言的UIのメリットを活かしきれません。
重要なのは、「描画」と「ロジック」、「デザイン」と「構造」、「ライブラリ」と「アプリケーション」を適切に分離し、モジュール間の結合度を下げ、塊で捨てやすい設計にすることです。記事で解説したContainer&Presentationalパターンは、この分離を実現する一つの手段ですが、CSS-in-JSによる疑似状態の活用や、Hooksによるロジックの整理なども同じ目的を達成するアプローチです。
フロントエンドでは1年前のベストプラクティスが平気でアンチパターンに変わってしまいます。この変遷の早さに対応するには、各層の責任を明確に分離し、疎結合な設計を心がけることで、各部品・モジュールを任意の単位で捨てて0から作れるようにすることが不可欠です。
フロントエンドの開発に取り組む際は、単に新しい書き方を覚えるだけでなく、なぜその設計パターンが生まれたのか、どのような問題を解決しようとしているのかを理解し、関心の分離という設計思想に基づいた判断ができるよう心がけることをお勧めします。
Discussion