はじめに
React のカスタムフック便利ですよね。学び出した時から今でも「これぞコンポーネント指向の一例」とロジックの切り分けに精を出しています。
やりすぎた結果、実はコンポーネント内でロジックを閉じ込めておいた方が可読性や保守性が高かったりすることもありますが
カスタムフックの中ではuseState, useEffect, useRefなどお馴染みのAPIを組み合わせることも可能です。
まずは以下のカスタムフックを見てください。
これはSP/PCの判定用フックで、ブラウザの描画幅を認知(リサイズ検知)してStateを変えるものです。
export const useCheckDesktopView = () => {
const [isDesktopView, setDesktopView] = useState<boolean>(false);
useEffect(() => {
// effect 内で関数定義及び初期表示用に実行
const handleResize = (): void => {
const viewportWidth: number = document.body.clientWidth;
if (viewportWidth >= 1024) {
setDesktopView(true);
} else {
setDesktopView(false);
}
};
handleResize(); // 初期表示用に実行
window.addEventListener('resize', handleResize);
// クリーンアップ処理
return () => {
window.removeEventListener('resize', handleResize);
}
}, []);
return { isDesktopView };
}
このフックを複数のコンポーネントから呼ぶと、どこでも同じisDesktopViewが得られます。
そのため、筆者は「カスタムフックでグローバルStateを実現できる」と誤解していました。
実際グローバルStateを用意する場合、標準ではuseContext、ライブラリではJotai, Zustandなどを使うのが一般的です。概要や使い分けといった比較情報については以下の記事を確認してもらえると幸いです。
Stateの取り扱いについては、グローバルState以外にも props でバケツリレーしていく方法もあります(今回最終的に採用したのはこちらで、詳しくは後述します)。
話が少し逸れましたが、先に本記事で言いたいこと(結論)を書いておきます。
結論: カスタムフックで用意した State(及びセッター関数)は呼び出しごとに作成される値(State)である
先ほど提示したコードは「ブラウザ描画幅のリサイズ検知という『どのコンポーネントでも適用される全画面共通のロジック』によるState(状態)の変更」だから結果的にグローバルStateとして扱えている(ように見える)だけです。
つまり、これは疑似的にグローバルStateのように振る舞うだけで実際は (各コンポーネントで呼び出している)個別Stateが更新されている ことは理解しておくべきです。
カスタムフックが共有するのは「Stateの作り方(ロジック)」であり、Stateの実体はフックを呼び出す度に新しく作られるため、例えば、HogeAwesomeComponent, FooAwesomeComponent, PiyoAwesomeComponentなど各コンポーネントでカスタムフックを呼ぶとHoge-isDesktopView, Foo-isDesktopView, Piyo-isDesktopViewとして、それぞれのStateが用意されることになります。
つまり、これは「カスタムフックで用意した State(及びセッター関数)は個別の値・変数であり、グローバル(共有・共通)として扱うには工夫がいる」ということです。
カスタムフックを利用した State 共有例として、状態管理ライブラリZustandのuseStoreがあります。このライブラリでは「モジュールの外側に置いたストアをuseSyncExternalStoreで読み込む」という方法でカスタムフック経由で State を共有できるようにしています。
さて、本記事で伝えたい内容はここまでになります。
以降は筆者が今回ハマったケースと、その解決過程について書いていきます。
筆者がハマったケース
自作の AI アプリ(チャット / 音声対話 / 動画生成の 3モードをタブで切り替える埋め込み用パネル)で、「AI が稼働している間は、モード切替タブを押せないようにしたい」 という要件が出てきました。
最初は「カスタムフックを 1つ作って、各機能コンポーネントがそのフックを呼べば、グローバルラッパーのApp.tsxでも稼働状態が見られるはず」と考えて実装を始めました。
しかし、これでは 動きません。原因は、カスタムフックが共有するのは ロジック であって、State(値)ではない からです。
作りたかったもの(要件)
モードを切り替えると、そのモードのコンポーネントがアンマウントされるため、作業内容が失われる(※) のです。
そこで、AI稼働中(チャット送信中 / 音声対話中 / 動画生成中)は、モード切替タブを操作できなく(非活性化)したいと考えました。
- ※失われる作業内容
- チャット: 履歴がリセットされる
- 音声対話: セッションが切断されて対話ログが消える
- 動画生成: 生成中の結果が失われる
大人しくグローバルState(例: Context API--useContext など)を使っても良かったのですが、APIキーを持たずにエンドポイントURLへ繋ぐだけで使用できる汎用キット化の構想がありました(※現状は Cloudflare Workers / Hono のバックエンドと各種機能のフロントエンドが混在したモノレポ構成)。
合わせて、極力ミニマルにしたいと考えていたので、グローバルStateを避けるアプローチを採ったのです(例:切り出すときにProviderで包む必要が出る、他のモードのContextに依存してしまう、など)。
例えば、音声モードだけなど特定機能に絞って流用する場合に、他モードの有無による影響が出ないようにしたかったわけですね。
当初のアプローチ
当初は記事冒頭の例示コードのように、カスタムフックの中にuseStateを持たせて、各機能のフックから「稼働中かどうか」を通知する形にしていました。
// 当初のカスタムフック(要点のみ)
export const useCheckAIActivate = () => {
const [isAIActivate, setAIActivate] = useState<boolean>(false);
const checkAIActivate = (currState: boolean): void => {
setAIActivate(currState);
}
return { isAIActivate, checkAIActivate }
}
アプローチ意図は以下です。
-
App.tsxではconst { isAIActivate } = useCheckAIActivate();で読み - チャット・音声・動画の各フックでは
const { checkAIActivate } = useCheckAIActivate();を呼び - 処理の前後で
checkAIActivate(true / false)を呼ぶ
しかしこれでは期待した挙動になりませんでした。
理由は、カスタムフックは「State の作り方の手順=ロジックの共有」であり、共有されるのは1つの箱(State / 値)ではないためです。
呼ぶ度に、呼んだコンポーネントごとに別々の箱が作られます。
- A. App 用箱: App が
useCheckAIActivate()を呼ぶ => App-isAIActivate - B. チャット用箱: チャットのフックが
useCheckAIActivate()を呼ぶ => Chat-isAIActivate - C. 音声用箱: 音声のフックが
useCheckAIActivate()を呼ぶ => Voice-isAIActivate - D. 動画用箱: 動画のフックが
useCheckAIActivate()を呼ぶ => Video-isAIActivate
子(各モードコンポーネントや関連フック)が checkAIActivate(true) を呼んで更新するのは 自分の箱 です。App が読んでいるのは箱 A なので、いつまでも false のままです。
ここは少しややこしいのですがAppは「最初(マウント時)の状態(State: App-isAIActivate)しか見えない」のではなく、そもそも別の State を見ているのがポイントです。App は自分の箱 A(App-isAIActivate)しか知らず、箱 B〜D の存在すら知りません。
混乱の原因: useCheckDesktopView は State を共有しているように見える
筆者が混乱した原因の 1 つが、プロジェクト内にあった既存フック(※記事冒頭で説明したuseCheckDesktopViewのもの)でした。
どのコンポーネントから呼んでも isDesktopView は同じ値になるので、State を共有しているように見えます。
しかし実際は、「A が呼ぶ → 箱 A ← window の resize を購読」で、「B が呼ぶ → 箱 B ← window の resize を購読(別の箱だが、値は常に同じ)」という状況でした。
つまり、「ブラウザ描画幅のリサイズ検知という『どのコンポーネントでも適用される全画面共通のロジック』によるState(状態)の変更」だから 結果的にグローバルStateとして扱えている(ように見える) だけです。
判断軸としては「状態遷移の依存先(トリガー)が『全画面共通または外部起点のロジックや値』か『各コンポーネントに限定された、または直接的に関連するロジックや値』であるか」ということです。
- 前者の場合は 結果的にグローバルStateとして扱える(ように見える) 形に持っていけます
- ※ただし、これは疑似的にグローバルStateのように振る舞うだけで実際は (各コンポーネントで呼び出している)個別Stateが更新されている ことは理解しておくべきです
- 後者の場合は親から子への Props 渡し or グローバルState(useContext / Jotai / Zustand) などを検討します
筆者は今回「親から子への Props 渡し」を採用しました。
Appが内包する各モードのコンポーネントには、それぞれがAIとの通信状態(loading State)を持っていたので、1つ下の階層への Props 渡しで済んだためです。
実は初期フェーズの実装前で、この最もシンプルな実装も考えていました。
しかし、各コンポーネントで以下のようなuseEffect処理(※)が必要になるので、不必要な副作用処理はあまり使いたくない(宗教的な)理由で避けていたのです。
このAI全盛時代において自分のコーディングへのこだわりもアレな気がしますが、この props 渡しを結局採ったのは「直感的で理解しやすい普遍的なアプローチ」という部分が大きかったためです。
- ※必要となる
useEffect処理
Reactのレンダリングサイクルのルール上、レンダー中に別コンポーネント(親)のStateを更新できないため副作用が必要となります。
useEffect(() => {
setAIActivate(loading);
}, [loading, setAIActivate]);
なお、useEffectを使わない方法としては「対象のState(loading)を更新するハンドラの中で親のセッター関数も一緒に呼ぶ」というアプローチがあります。
// このハンドラを処理の前後(今回の場合だとAIレスポンスの前後箇所)で使用する
const handleChatActive = (active: boolean): void => {
setAIActivate(active);
setLoading(active);
};
今回は呼び出し箇所が9〜10か所に増え、書き忘れのリスクがあったため見送りました。
まとめ
カスタムフックで用意した State(及びセッター関数)は呼び出しごとに作成される値(State)です。
判断軸としては「状態遷移の依存先(トリガー)が『全画面共通または外部起点のロジックや値』か『各コンポーネントに限定された、または直接的に関連するロジックや値』であるか」となります。
前者は各コンポーネントが同じ外部の値を読むため、結果的に値が揃って見える(グローバルStateのように見えるだけで、Stateは個別です)。後者は親から子への Props 渡し or グローバルState(useContext / Jotai / Zustand) などを検討します。
使い分けの目安
| 状況 | 選択 |
|---|---|
| props の階層が 1〜2 段、値が少ない | props で渡す(State の持ち上げ) |
| props の階層が 3 段以上になる | Context を検討 |
| 離れた複数の場所で読み書きしたい、別のツリーからも使いたい | 外部ストア(Zustand など)を検討 |
「共有したい値があるが、深い階層まで届けたくない」状況になって初めて、Context やストアを検討すれば十分です。今回の setAIActivate を Context やストアへ置き換える作業は、小さく済みます。
さいごに
カスタムフックを使い慣れていると、「フックにすれば何でも共有できる」と誤解していました。
自分の混乱を整理するつもりで書きましたが、同じところで悩んでいる方の参考になれば幸いです。
蛇足ですが、今回久しぶりに自分でReactを少し書いてみるとprops渡しという基本的な構文をド忘れしていて少し焦りました。
まぁもう自分で書いていく機会も減っていくのでしょうが、AI慣れ(頼りすぎ)の弊害かもしれません。
ここまで読んでいただき、ありがとうございました。

