React.Context で作る GlobalUI Custom Hooks
GlobalUI を、React.Context
を利用して作る TIPS です。どの Component からも利用しやすい様に Custom Hooks を経由します。何かを更新した際、画面上部に通知を表示する(Notification)よくあるサンプルを元に解説します。(↓ の画像の様なもの)
Custom Hooks 利用例
Notification を呼び出す Component は次の様なコードになります。showNotification
関数に任意の文字列を与え表示する、というシンプルな作りです。
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 を経由して分配されていることがわかります。
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 を要求する構造になってしまいます。
export const Example = () => {
// state の変更も再描画要因になるため、React.memo などで抑止が必要になってしまう
const { showNotification } = React.useContext(NotificationContext);
const handleClick = () => {
showNotification("更新しました");
};
return <button onClick={handleClick}>通知を表示する</button>;
};
これを未然に防ぐため「状態参照系・更新系」で Context を二分割します。「状態参照系・更新系」がそれぞれの value を経由して分配されていることが分かります。
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 で再描画は発生しなくなります。
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 を渡しているだけ。次の例がその全容です。
export function useNotificationDispatch() {
return React.useContext(NotificationDispatchContext);
}
state を利用する GlobalUI 向けに、状態を参照する Custom Hooks も次の様になります。state が更新された時のみ、再描画対象となる Custom Hooks とすることができます。
export function useNotificationState() {
return React.useContext(NotificationStateContex);
}
この様に裏側の利用 API を隠蔽することで、Component に影響することなくロジックの修正が可能になります。Redux など他の状態管理ライブラリへ移行したくなった場合なども、Custom Hooks 内に修正範囲を閉じることができます。
3.「初期値注入」をできる親 Provider Container Component を用意する
Storybook・test で初期値を注入できる機構が必要になります。二分割した Provider を包括する「Provider Container Component」にinitialState
を注入できるインターフェイスを設けることで、これに対応することができます。
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 は何でも構いません)
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
が内部で利用されています。
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
を呼び出すことができる機構が完成しました。
function App({ Component, pageProps }: AppProps) {
return (
<>
<NotificationProviderContainer>
<Component {...pageProps} />
<NotificationConsumer />
</NotificationProviderContainer>
</>
);
}
Discussion