【テクニカル・上級編】 Controlled/UncontrolledコンポーネントのProps設計 – React実践ガイド

こんにちは。フロントエンドの現場で日々、コンポーネントの生死を見つめているチーフアーキテクトだ。

今日もコードレビューをしていたら、また見かけてしまった。「外部から `value` を渡せば制御可能(Controlled)になり、渡さなければ内部でよしなに状態を持つ(Uncontrolled)ようにしたいんです」という、一見美しく、しかし実装者の精神を確実に蝕む魔界のコンポーネントを。

「どちらでも動くようにする」というのは、優しさのつもりで実装すると、大抵はどちらのユースケースでもバグを孕む中途半端なキメラを生み出す。Reactの内部レンダリングメカニズム、そしてDOMとの同期の不整合を深く理解していないと、この「ハイブリッド・コンポーネント」の罠に足を取られ、非同期の競合(Race Condition)やメモリリークの温床を作り上げてしまう。

今回は、この制御(Controlled)と非同期・内部管理(Uncontrolled)の境界線を美しく、かつ実務で絶対に破綻しないレベルで調停するためのProps設計パターンについて、私の知見をすべて注ぎ込んで解説しよう。

—

なぜ「ハイブリッド・コンポーネント」は破綻するのか?

まず大前提として、Reactにおけるコンポーネントは「UIの純粋な関数」であるべきだ。
Controlledなコンポーネントは、親がすべてのStateを握り、子は受け取ったPropsを描画し、変更があれば親にコールバックで通知する。単方向データフローの美しき具現化だ。

一方で、Uncontrolledなコンポーネントは、内部に `useState` や `useRef` を持ち、外界から遮断されたカプセル化された世界で生きる。

この2つを「`value` が `undefined` かどうかで内部Stateを初期化し、その後は……」といったアドホックな条件分岐で繋ごうとすると、何が起きるか?

1. 二重真実のソース(Single Source of Truthの崩壊):親のStateと子の内部Stateが乖離する瞬間が必ず訪れる。
2. 非同期の競合(Race Condition):外部からの `value` の変更と、内部の入力イベントが交錯したとき、DOMのカーソル位置が吹き飛んだり、入力中の値が強制上書きされる地獄のUXが完成する。
3. メモ化の無効化と無駄な再レンダリング:内部で無理やり状態を同期させようと `useEffect` を乱用した結果、無限ループや不要なレンダリングサイクルが爆誕する。

では、プロフェッショナルはこの難題にどう立ち向かうのか。答えは明確だ。「責務の分離(Separation of Concerns)」と「明確なモードの切り替え」である。

—

堅牢なProps設計:Controlled-Uncontrolled Bridge パターン

実務において最も推奨されるアプローチは、「デフォルト値を受け取るUncontrolled」と「外部制御されるControlled」のインターフェースを明確に定義し、内部での状態の曖昧さを排除すること」だ。

以下のコードを見てほしい。これは、単なる「動くコード」ではなく、ブラウザの入力イベントのライフサイクルとReactのファイバーツリーの挙動を極限まで考慮した実装だ。

import React, { useState, useCallback, useId } from ‘react’;

// 1. 型定義の分離
// 共通のベースProps
interface BaseTextFieldProps {
label: string;
placeholder?: string;
disabled?: boolean;
className?: string;
}

// Controlled用のProps定義
interface ControlledTextFieldProps extends BaseTextFieldProps {
value: string;
defaultValue?: never; // アンコントロール時の値の混入を防ぐためのTypeScriptの型ガード
onChange: (value: string) => void;
}

// Uncontrolled用のProps定義
interface UncontrolledTextFieldProps extends BaseTextFieldProps {
value?: never;
defaultValue?: string;
onChange?: (value: string) => void;
}

export type TextFieldProps = ControlledTextFieldProps | UncontrolledTextFieldProps;

export const TextField: React.FC = ({
label,
value: externalValue,
defaultValue = ”,
onChange,
disabled = false,
placeholder,
className,
}) => {
// 2. 制御モードの判定(コンポーネントのライフサイクルを通じて不変)
// Reactのレンダリング中に判定することで、途中でモードが切り替わるバグをコンパイルレベル・実行時レベルで防ぐ
const isControlled = externalValue !== undefined;

// 3. 内部状態の保持(Uncontrolledモード時のみ実質的に機能する)
const [internalValue, setInternalValue] = useState(defaultValue);

// 真の現在地を決定
const currentValue = isControlled ? externalValue : internalValue;

// アクセシビリティのためのユニークID生成
const id = useId();

// 4. イベントハンドラーの最適化
// 不必要な再生成を防ぐため、依存配列を最小限に抑える
const handleChange = useCallback(
(e: React.ChangeEvent) => {
const newValue = e.target.value;

// Uncontrolledモードの場合は内部Stateを更新
if (!isControlled) {
setInternalValue(newValue);
}

// 外部へ変更を通知(Controlledモードの生命線)
if (onChange) {
onChange(newValue);
}
},
[isControlled, onChange]
);

return (


);
};

—

アーキテクチャの急所:なぜこの設計が優れているのか?

上記のコードには、単なる「動くコンポーネント」を超えた、アーキテクトとしてのこだわりが詰まっている。

1. `never` 型を用いた排他的なProps設計(TypeScriptの恩恵)

`ControlledTextFieldProps` では `defaultValue?: never` を指定し、`UncontrolledTextFieldProps` では `value?: never` を指定している。これにより、開発者がうっかり `value` と `defaultValue` の両方を同時に渡してしまった瞬間、TypeScriptのコンパイラがエラーを吐き出す。
「どちらの挙動になるか分からない」という曖昧さを、型システムによってビルド時に完全に駆逐するのだ。

2. 制御モードのイミュータビリティ

`const isControlled = externalValue !== undefined;`
この判定をコンポーネントのトップレベルで行っている点が極めて重要だ。
もし途中で `undefined` から値ありに変わるような(あるいはその逆の)アンチパターンを書いた場合、ReactのHooksのルールに違反するか、あるいはファイバーツリーの同期が狂い、Reactが「これはControlledからUncontrolledへの不法な移行だ」とコンソールで怒り狂うことになる。
初期描画時に「どちらの生命維持装置で生きるか」を決定し、それを最後まで貫き通すのが美学だ。

3. メモリ効率と不要なレンダリングの回避

Uncontrolledモードの際、親コンポーネントは再レンダリングされる必要がない。子コンポーネントが自前の `internalValue` を抱えているため、タイピングのたびに親の全ツリーを巻き込んで再描画(Re-render Cascades)が走るのを防げる。
これは、特に巨大なフォームやリスト内の各行に配置される入力コンポーネントにおいて、パフォーマンスを救う決定打となる。

—

さらに上の高みへ:非同期の競合(Race Condition)への備え

実務で最も厄介なのは、「外部からの `value` が非同期APIのレスポンスに依存している場合」だ。
例えば、サーバーからデータを取得するまでの間にユーザーがフォームに入力し、その直後に遅れてサーバーからのデータが返ってきて `value` が強制上書きされ、ユーザーが入力した文字が消えるという、ユーザービリティの殺人事件が起きる。

これを防ぐためには、コンポーネント内部で「ユーザーが現在アクティブに編集中かどうかのフラグ(Dirty State)」を持つか、あるいは親側でしっかりと非同期の競合をコントロールする必要がある。
しかし、コンポーネント単体で最低限の自衛をするならば、`useEffect` で外部からの `value` の変化を内部Stateに強制同期させる実装は、禁忌(Anti-pattern)であることを覚えておいてほしい。

どうしても外部からの非同期変更を検知して内部Stateを同期させたい場合は、以下のように「前回のPropsとの比較」を明示的に行うか、そもそも設計を見直して「完全にControlledに強制する」べきだ。

// 危険な useEffect による同期の例(非推奨)
// useEffect(() => {
// setInternalValue(externalValue);
// }, [externalValue]);
// ↑これをやると、ユーザーの入力中のフォーカスやカーソル位置が破壊されるバグの温床になる。

もし非同期のデータバインディングを行うなら、コンポーネントは常に `Controlled`(`value` と `onChange` のみを受け付ける)として強制し、Uncontrolledとしての側面を捨て去る方が、長期的なコードベースの健康状態は圧倒的に良くなる。設計の妥協は、後々の技術的負債という名の利息を生むだけだ。

—

まとめ

Controlled/UncontrolledのハイブリッドなProps設計は、一見すると汎用性が高く見えて、その実、予測不可能なバグを生み出すパンドラの箱だ。

  • 型(TypeScript)の力を借りて、両者を完全に分離する(`never` の活用)。
  • コンポーネントのライフサイクルを通じて、どちらのモードで動くかを固定する。
  • パフォーマンスと単方向データフローの原則を常に念頭に置く。

この鉄則を守るだけで、あなたの書くUIコンポーネントは、どんなに巨大なエンタープライズアプリケーションに組み込まれても、決して音を上げない堅牢な要塞と化すだろう。

コードは嘘をつかない。設計の美しさは、そのままアプリケーションの品質に直結する。さあ、今すぐ君のコードベースにある「どっちつかずのコンポーネント」をリファクタリングしに行こうか。

コメント

タイトルとURLをコピーしました