React.Context で作る GlobalUI Custom Hooks

6 min read読了の目安(約6000字

GlobalUI を、React.Context を利用して作る TIPS です。どの Component からも利用しやすい様に Custom Hooks を経由します。何かを更新した際、画面上部に通知を表示する(Notification)よくあるサンプルを元に解説します。(↓ の画像の様なもの)

notification

Custom Hooks 利用例

Notification を呼び出す Component は次の様なコードになります。showNotification関数に任意の文字列を与え表示する、というシンプルな作りです。

Example.tsx
export const Example = () => {
  const { showNotification } = useNotificationDispatch();
  const handleClick = () => {
    showNotification("更新しました");
  };
  return <button onClick={handleClick}>通知を表示する</button>;
};

ポイントは次の3点。

  • 1.「状態参照系・更新系」で Context は二分割する
  • 2.「状態参照系・更新系」で Custom Hoooks は二分割する
  • 3.「初期値注入」をできる親 Provider Container Component を用意する

3つの Custom Hooks が登場します。

  • useNotificationCore
  • useNotificationDispatch
  • useNotificationState

1.「状態参照系・更新系」で Context は二分割する

Custom Hooks 作成の前に、Context を「状態参照系・更新系」に二分割する必要性に触れます。Context は細分化することで、利用する Component の再描画を最小限にすることが出来ます。次の例は悪い Provider の例で、状態と更新ハンドラがまとめて value を経由して分配されていることがわかります。

NotificationProviderContainer.tsx
export const NotificationProviderContainer: React.FC = (props) => {
  const { state, showNotification, hideNotification } = useNotificationCore();
  return (
    <NotificationContex.Provider
      value={{ state, showNotification, hideNotification }}
    >
      {props.children}
    </NotificationContex.Provider>
  );
};

この場合、useContextしている Component では更新ハンドラのみを利用しているだけにも関わらず、state の変更にあわせて再描画が発生してしまいます。これが原因でReact.memoを使用せざるを得ない子 Component を要求する構造になってしまいます。

Example.tsx
export const Example = () => {
  // state の変更も再描画要因になるため、React.memo などで抑止が必要になってしまう
  const { showNotification } = React.useContext(NotificationContext);
  const handleClick = () => {
    showNotification("更新しました");
  };
  return <button onClick={handleClick}>通知を表示する</button>;
};

これを未然に防ぐため「状態参照系・更新系」で Context を二分割します。「状態参照系・更新系」がそれぞれの value を経由して分配されていることが分かります。

NotificationProviderContainer.tsx
export const NotificationProviderContainer: React.FC = (props) => {
  const { state, showNotification, hideNotification } = useNotificationCore();
  return (
    <NotificationStateContext.Provider value={{ state }}>
      <NotificationDispatchContext.Provider
        value={{ showNotification, hideNotification }}
      >
        {props.children}
      </NotificationDispatchContext.Provider>
    </NotificationStateContext.Provider>
  );
};

この構造とすることで、更新系ハンドラをuseContext する Component で再描画は発生しなくなります。

Example.tsx
export const Example = () => {
  // state の変更は再描画要因にならない
  const { showNotification } = React.useContext(NotificationDispatchContext);
  return (...);
};

2.「状態参照系・更新系」で Custom Hoooks は二分割する

ここまでで大枠の構造ができました。次にReact.useContextを Custom Hooks 経由で隠蔽します。これにより、showNotification関数を利用する Component に、裏側の利用 API(React.useContext)が漏洩しません。また、副作用の扱いなども見えなくなり、Component 本来の責務に集中できます。

冒頭で紹介した Custom Hooks useNotificationDispatchは「更新系ハンドラ」を含む Context value を渡しているだけ。次の例がその全容です。

useNotificationDispatch.ts
export function useNotificationDispatch() {
  return React.useContext(NotificationDispatchContext);
}

state を利用する GlobalUI 向けに、状態を参照する Custom Hooks も次の様になります。state が更新された時のみ、再描画対象となる Custom Hooks とすることができます。

useNotificationState.ts
export function useNotificationState() {
  return React.useContext(NotificationStateContex);
}

この様に裏側の利用 API を隠蔽することで、Component に影響することなくロジックの修正が可能になります。Redux など他の状態管理ライブラリへ移行したくなった場合なども、Custom Hooks 内に修正範囲を閉じることができます。

3.「初期値注入」をできる親 Provider Container Component を用意する

Storybook・test で初期値を注入できる機構が必要になります。二分割した Provider を包括する「Provider Container Component」にinitialStateを注入できるインターフェイスを設けることで、これに対応することができます。

NotificationProviderContainer.tsx
type Props = {
  initialState?: Partial<State>;
};
export const NotificationProviderContainer: React.FC<Props> = (props) => {
  const { state, showNotification, hideNotification } = useNotificationCore(
    props.initialState // ここで注入
  );
  return (
    <NotificationStateContext.Provider value={{ state }}>
      <NotificationDispatchContext.Provider
        value={{ showNotification, hideNotification }}
      >
        {props.children}
      </NotificationDispatchContext.Provider>
    </NotificationStateContext.Provider>
  );
};

useNotificationCoreは「Provider Container Component」専用の Custom Hooks です。props.initialStateを受け取り、初期表示に対応します(サンプルでは React.useReducer を利用していますが、ここで利用する API は何でも構いません)

useNotificationCore.ts
export type State = {
  message: string;
  isShow: boolean;
};
const initialStateFactory = (initialState?: Partial<State>): State => ({
  message: "",
  isShow: false,
  ...initialState,
});
function reducer(state: State, action: Action): State {
  // reducer処理
}
export function useNotificationCore(initialState?: Partial<State>) {
  const [state, dispatch] = React.useReducer(
    reducer,
    initialStateFactory(initialState)
  );
  const showNotification = React.useCallback((message: string) => {
    dispatch({ type: "SHOW", message });
  }, [dispatch]);
  const hideNotification = React.useCallback(() => {
    dispatch({ type: "HIDE" });
  }, [dispatch]);
  //
  return {
    state,
    showNotification,
    hideNotification,
  } as const;
}

導入

Storybook で初期値を注入する例です。この様なインターフェイスがあれば、SSR にも対応することができます。NotificationConsumerは、Notification を表示する Component で、useNotificationStateが内部で利用されています。

NotificationProviderContainer.stories.tsx
const Template: Story<Props> = ({ ...args }) => (
  <NotificationProviderContainer {...args}>
    <NotificationConsumer />
  </NotificationProviderContainer>
);

export const Index: Story<Props> = Template.bind({});
Index.args = {
  initialState: { message: "initial message", isShow: true },
};

Next.js で利用する場合はこの様になります。これで、どこからでもshowNotificationを呼び出すことができる機構が完成しました。

_app.tsx
function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <NotificationProviderContainer>
        <Component {...pageProps} />
        <NotificationConsumer />
      </NotificationProviderContainer>
    </>
  );
}