こんにちは。フロントエンドチームのアーキテクートをやっている私だ。
日々、コードレビューをしていて「おっ、いい感じにコンポーネントが分割されているな」と感心することもあれば、「あぁ、ここでそのPropsの設計にしちゃったか……」と頭を抱える夜もある。
特に中級からシニアへ駆け上がろうとしているエンジニアがつまずきやすいのが、「Controlled(制御された)コンポーネント」と「Uncontrolled(非制御)コンポーネント」の境界線の引き方だ。
「外部から値を完全にコントロールさせたいけれど、コンポーネント単体でもサクッと動いてほしい」
「親から `value` と `onChange` を渡されたときと、渡されない(初期値だけ `defaultValue` として受け取る)ときで、内部の挙動がごちゃ混ぜになってバグの温床になっている」
――心当たりはないだろうか?
今回は、実務の現場で頭を悩ませる「Controlled/Uncontrolledを美しく両立させるProps設計」について、ブラウザの挙動やReactの哲学を踏まえつつ、徹底的に解説しよう。明日からのコードが劇的に変わるはずだ。
—
なぜ、Controlled と Uncontrolled のハイブリッドが必要なのか?
まず前提を共有しよう。Reactの入力コンポーネント(inputやselectなど)には、大きく分けて2つのアプローチがある。
1. Controlled Component(制御されたコンポーネント)
- 値の真実の源(Single Source of Truth)を親コンポーネントのstateに置く方式。
- `value` と `onChange` をペアで受け取り、入力のたびに親を再レンダリングして値を同期させる。
- バリデーションや動的な入力制限(例:全角強制、数値のみ)をかけやすい反面、単純な入力フォームでも毎回ボイラープレート(状態管理用のコード)を書く必要があり、開発体験が重くなりがち。
2. Uncontrolled Component(非制御コンポーネント)
- 値の真実の源をDOM自身(またはコンポーネント内部のref)に持たせる方式。
- `defaultValue` を受け取り、DOMが勝手に状態を保持する(HTMLのネイティブな振る舞いに近い)。
- サクッと書けてパフォーマンスも良いが、親から外側アプローチで値を強制的に書き換えるのが難しくなる。
現場のジレンマ
デザインシステムや共通UIライブラリを作っていると、この2つのジレンマに直面する。
「ある画面ではバリデーションを厳しくやりたいからControlledで使いたい。でも、別の画面のモーダルの中にある簡易的な入力欄なら、Uncontrolledのノリで `defaultValue` だけ渡してサクッと使わせたい!」
この要望に応えるための究極の解が、「外部から `value` が渡ってきたらControlledとして振る舞い、渡されなければ(undefinedなら)内部で勝手に状態を持つUncontrolledとして振る舞う」というハイブリッド設計だ。
—
ブラウザの裏側とReactの「二重管理」の罠
ここで少し立ち止まって、ブラウザが裏側でどう動いているか、そしてReactがそれにどう介入しているかを思い出してほしい。
ネイティブの `` は、ユーザーがキーボードを叩くたびに、ブラウザのC++層(DOMの実装)が内部バッファを更新し、画面のピクセルを再描画する。
一方、ReactのControlledコンポーネントは、キー入力イベント(`onChange`)をフックして親のStateを更新し、Reactの仮想DOMツリー全体を再構築してから、新しい `value` をDOMに押し戻すというアクロバティックな裏技をやっている。
この仕組みを自作のカスタムコンポーネントに落とし込むとき、何も考えずに `useState` を置くだけだと、「親の `value`(プロップス)」と「内部の `useState`(ローカルステート)」が同期ズレを起こすという、React開発者なら誰もが一度は踏む地雷を踏み抜くことになる。
「親から渡された `value` が変わったのに、内部の `state` が古いまま更新されない!」というバグの原因はまさにこれだ。
—
実装パターン:美しく型付けされたハイブリッド・コンポーネント
百聞は一見にしかず。実務でそのまま使える、極めて堅牢なカスタム入力コンポーネントのコードを見てほしい。TypeScriptの型定義も含めて、プロの作法を詰め込んでいる。
import React, { useState, useEffect, ChangeEvent } from ‘react’;
/
- プロパティの型定義
- 外部からの制御(value/onChange)と、内部での自律管理(defaultValue)の両方に対応する
/
export interface SmartInputProps {
/ 外部から制御する場合の値 /
value?: string;
/ 外部から初期値だけを与えて非制御で使う場合の値 /
defaultValue?: string;
/ 値が変更されたときのコールバック /
onChange?: (value: string) => void;
/ プレースホルダー /
placeholder?: string;
/ ラベル /
label: string;
}
export const SmartInput: React.FC
value: externalValue,
defaultValue = ”,
onChange,
placeholder,
label,
}) => {
// 1. 現在のモードが「Controlled」か「Uncontrolled」かを判定する
// 外部から value プロップスが undefined 以外で渡されていれば Controlled とみなす
const isControlled = externalValue !== undefined;
// 2. 内部ステートの初期化
// Controlled の場合は externalValue、Uncontrolled の場合は defaultValue を初期値とする
const [internalValue, setInternalValue] = useState
isControlled ? externalValue : defaultValue
);
// 3. 【重要】Controlled モードの場合、親から渡される value の変更に内部ステートを追従させる
// (これがないと親側で値が書き換わったときに画面が同期しなくなる)
useEffect(() => {
if (isControlled) {
setInternalValue(externalValue);
}
}, [isControlled, externalValue]);
// 4. 入力値変更ハンドラー
const handleChange = (e: ChangeEvent
const newValue = e.target.value;
// Uncontrolled モードの場合のみ、自前のステートを更新する
// (Controlled の場合は親の state が更新され、上記 useEffect 経由で値が降りてくるのを待つ)
if (!isControlled) {
setInternalValue(newValue);
}
// 親コンポーネントが onChange を要求している場合は必ず発火させる
if (onChange) {
onChange(newValue);
}
};
return (
);
};
—
コードの解説:なぜこの設計が「プロの技」なのか?
このコードには、実務で数々の修羅場を潜り抜けてきたエンジニアなら思わずうなずく、いくつかの重要な設計思想が隠されている。
1. `isControlled` フラグによるモードの動的判定
`const isControlled = externalValue !== undefined;`
この1行がすべての鍵を握る。Reactの公式でも推奨されているパターンだが、コンポーネントのライフサイクル途中で「ControlledからUncontrolledへ(またはその逆へ)」モードが切り替わることは基本的にアンチパターン(バグの元)とされるため、初期描画またはレンダリング時のプロップスの存在有無で挙動を固定化するのが最も安全だ。
2. `useEffect` による外部変更の同期
Controlledモードのとき、親が持っている `value` が変わったら、子側の `internalValue` も追従させなければならない。
ここで `useEffect([externalValue])` を使い、親からの変更をキャッチして内部ステートを上書きしている。これが抜けている実装を世の中のコードレビューで非常によく見かけるので、皆さんは絶対に忘れないようにしてほしい。
3. イベントハンドラー内でのモード分岐
`if (!isControlled) { setInternalValue(newValue); }`
ここが一番のポイントだ。
もしControlledモードであれば、子コンポーネントが勝手に `setInternalValue` を呼んではいけない。「真実の源」はあくまで親にあるため、親の `onChange` を呼んで、親にステートを変えてもらい、その結果をプロップス経由で上から流し込んでもらう(リフト・アップ・ステート)必要がある。
一方、Uncontrolledモードであれば、誰も親側で値を持っていないため、子自身が `setInternalValue` を呼んで画面を書き換える必要がある。
—
実際の使い方(親コンポーネントからの視点)
この `SmartInput` を、親コンポーネントからどのように使い分けるのか、使用例を見てみよう。
import React, { useState } from ‘react’;
import { SmartInput } from ‘./SmartInput’;
export const ParentComponent: React.FC = () => {
// パターンA: 完全に Controlled として使いたい場合
const [controlledText, setControlledText] = useState(‘初期値(制御)’);
return (
Props設計のテスト画面
{/ パターンA: Controlled な使い方 /}
/>
現在の親のステート: {controlledText}
{/ パターンB: Uncontrolled な使い方(defaultValueだけ渡す) /}
/>
);
};
親から見れば、`value` を渡せば制御モードになり、渡さなければ非制御モードになる。コンポーネントの使い手が「今どっちのモードを意識しなきゃいけないんだっけ?」と悩む時間をゼロにできる。これが優れたAPI設計だ。
—
シニアからの最後の小言
フロントエンドのアーキテクチャにおいて、優れたProps設計とは「使う側の認知負荷を最小限にしつつ、拡張性の担保とバグの温 setores(温床)を排除すること」に他ならない。
「動けばいいや」と場当たり的に `useState` を仕込んだり、無理やり複雑なフラグを外から渡させたりするコードは、半年後の自分やチームメンバーを確実に苦しめる。
今回紹介した「ハイブリッドPropsパターン」は、自作のカスタムUIライブラリを作る際や、再利用性の高いフォーム部品を設計する際には必ずと言っていいほど役立つ定番のプラクティスだ。
ぜひ、明日の開発から自分のプロジェクトに取り入れてみてほしい。コードの美しさと堅牢性が一段階上のレベルへ引き上げられるはずだ。

コメント