Kodokon kodokon.com

エキスパートパターン: コンパウンドコンポーネント、制御の反転

コンパウンドコンポーネント、state reducer、そして render props とフックの適切な選択によって、柔軟なコンポーネント API を設計します。

10 分 · 3 問

このレッスンを Kodokon で開く

<Tabs items={...} labels={...} icons={...} /> というコンポーネントは、いつも設定用の props に埋もれてしまいます。コンパウンドコンポーネント パターンはアプローチを逆転させます。複数のコンポーネントが協調し、コンテキストを通じて 暗黙的な state を共有する一方で、ユーザーは JSX の構成(順序、ネスト、要素の挟み込み)の主導権を保ちます。これは HTML の <select><option> のモデルであり、Radix や Headless UI のようなライブラリで使われているものです。

JSX
const TabsContext = createContext(null);

function Tabs({ defaultTab, children }) {
  const [active, setActive] = useState(defaultTab);
  const value = useMemo(
    () => ({ active, setActive }),
    [active]
  );
  return (
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}
親はメモ化されたコンテキストを通じて共有 state を公開します。

各サブコンポーネントはコンテキストを読み取り、配線用の props をまったく必要としません。ユーザーは自由に構成でき、パターンが一貫性を保証します。コンテキストの値が useMemo でラップされていることに注目してください。これがないと、親のレンダーのたびに新しいオブジェクトが生成され、すべての コンシューマーのレンダーが強制されてしまいます。

JSX
function Tab({ id, children }) {
  const ctx = useContext(TabsContext);
  return (
    <button
      aria-selected={ctx.active === id}
      onClick={() => ctx.setActive(id)}
    >
      {children}
    </button>
  );
}

// <Tabs defaultTab="a">
//   <Tab id="a">Profile</Tab>
//   <Tab id="b">Settings</Tab>
// </Tabs>
配線用の props は不要: コンテキストが接続を担います。

2 つ目のパターン: 制御の反転。その最も洗練された形が、Kent C. Dodds が Downshift で広めた state reducer です。新しいニーズが出るたびに boolean の prop を追加するのではなく、フックは自身の state 遷移 を公開します。呼び出し側が reducer を提供し、それが各アクションを受け取って、通過させるか、変更するか、キャンセルするかを決められます。ライブラリはデフォルトの挙動を保ち、きめ細かな制御はコンシューマーに委ねられます。

JSX
function defaultReducer(state, action) {
  if (action.type === 'toggle') {
    return { on: !state.on };
  }
  return state;
}

function useToggle({ reducer = defaultReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, {
    on: false,
  });
  const toggle = () => dispatch({ type: 'toggle' });
  return { on: state.on, toggle };
}
呼び出し側はすべての state 遷移を傍受できます。

最後に、永遠のテーマである render props 対フック の議論です。ロジックの共有 においてはフックが勝利しました。ツリー内の偽の階層もなく、ラッパーのピラミッドもなく、複数のソースを自然に組み合わせられます。しかし render prop には正当な領域が残っています。変化が レンダリングそのもの に関わる場合です。各行の描き方を尋ねてくる仮想化リスト、すべての JSX を委譲するヘッドレスコンポーネント。そこでは prop として渡される render 関数が最も直接的なツールであり続けます。フックは呼び出し側のツリーの どこに JSX を挿入するかを決められないからです。

JSX
function MouseTracker({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const onMove = (e) =>
    setPos({ x: e.clientX, y: e.clientY });
  return (
    <div onMouseMove={onMove}>{render(pos)}</div>
  );
}

// For the logic alone, a hook is enough:
// const pos = useMousePosition();
レンダリングを委譲するには render prop を、ロジックにはフックを。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. コンパウンドコンポーネントが明示的な props なしに state を共有できるのはどのメカニズムによるものですか?
    • 共有されたモジュール変数
    • 親コンポーネントが提供する React コンテキスト
    • 各子で state を複製する
    • cloneElement を介して受け渡される ref
  2. state reducer パターンは正確には何を反転させますか?
    • 親と子の間のデータフローの向き
    • state 遷移に対する制御。呼び出し側に委ねられる
    • フックの実行順序
    • fallback をレンダリングする責任
  3. どのような場合に render prop がフックより望ましいままですか?
    • コンポーネント間でロジックを共有したいとき
    • 呼び出し側がコンポーネントのツリーに挿入する JSX を提供しなければならないとき
    • コストの高い計算をメモ化する必要があるとき
    • コンポーネントが state を持たないとき