useRef は「再レンダリングを発生させない」ことを利点として使われるケースが多く、その性質を利用して状態を閉じ込める設計が一部で広がっています。しかし、これは一時的な制御には有効であっても、責務の曖昧化・同期性の崩壊・構造の不透明化といった重大な副作用を引き起こす原因となり得ます。

本稿では、useRef に状態を閉じ込める設計がどういったリスクを孕むかを、レビューアー視点で徹底的に読み解きます。

状態を閉じ込めるとはどういうことか

まず「状態を閉じ込める」とは、次のような構造を指します。

  • 外部からアクセスできない ref.current にフラグや値を格納し、状態変更のトリガーや判断材料に使う
  • useEffectaddEventListener の中で更新されるが、再レンダリングには影響を与えない
  • 状態が非同期処理と連動しており、ref.current の値によって処理の成否や終了判定が制御されている

こうした状態は一見するとパフォーマンスを損なわずに柔軟に制御できるように見えますが、UIの正確な再現性や副作用の可視性という点で大きな問題があります。

典型例:キャンセル判定のフラグを閉じ込める

useRefに状態を閉じ込める例
function useFetchData() {
  const cancelRef = useRef(false);

  useEffect(() => {
    cancelRef.current = false;

    fetch("/api/data")
      .then(res => res.json())
      .then(data => {
        if (!cancelRef.current) {
          console.log("処理実行");
        }
      });

    return () => {
      cancelRef.current = true;
    };
  }, []);
}
Comment
@Reviewer: 非同期処理のキャンセル判定にrefを使用していますが、状態の変更が再レンダリングやエラー処理に影響しない構造です。UI側からキャンセル状態が読み取れず、デバッグや再利用時の障害要因になります。

このような設計では、状態変更が「見えない場所で静かに」起きるため、開発者が予期しない挙動を起こす可能性があります。

リスク1:状態の可視性が失われる

useRef による状態保持は、開発者にとってReactの状態管理の原則である「変更=再レンダリング」の前提を崩す行為です。

レンダリングと無関係な値は意図的に切り離されるべきですが、描画に影響するような意味的フラグがrefに格納されていると、コードの表面的な可読性と実行時の状態とに乖離が生じます。

リスク2:テストと再利用性の低下

refの中に値が閉じ込められている構造では、単体テストや状態のモックが困難になります。

テスト時の問題例
expect(hookResult.current.cancelRef.current).toBe(true); // ← 非公開状態のアクセスが必要

このようなテストは内部実装に依存するため、将来的なリファクタリングや抽象化に耐えられず、実装詳細のリークを招く構造です。

リスク3:状態変更の通知がされないことによる副作用

たとえば、キャンセル判定がrefに閉じ込められている構造では、以下のような問題が起きます。

  • ユーザーが「キャンセルされた」と思っても、UI上に反映されない
  • ロジック的には処理が止まっていても、ユーザーには分からない
  • 状態がView層と同期していないため、UIが今の状態を表現していない

Reactは宣言的UIフレームワークであるため、「今の状態をUIに正確に映す」ことが最も重要です。useRefによってこれが崩れる構造は、原理的に避けるべきです。

責務分離された構造へのリファクタ例

状態をrefではなく明示的に useState に持ち出し、副作用をロジックHookに切り出す構造が望ましいです。

useStateで責務を分離した例
function useCancelableFetch() {
  const [canceled, setCanceled] = useState(false);

  useEffect(() => {
    setCanceled(false);

    fetch("/api/data")
      .then(res => res.json())
      .then(data => {
        if (!canceled) {
          console.log("実行");
        }
      });

    return () => setCanceled(true);
  }, []);

  return { canceled };
}
Comment
@Reviewer: 状態が外部に明示され、キャンセル判定も再レンダリングに反映可能です。責務がrefによって隠蔽されず、状態追跡やデバッグがしやすくなっています。

コメントで責務を補足できる構造かを確認

どうしてもrefに状態を置く必要がある場合(高頻度な変更、パフォーマンス制約など)、少なくとも次のように責務をコメントで補足し、構造的に明示する工夫が必要です。

const isRunning = useRef(false); // 描画と同期させる必要のない実行中フラグ

このように、コメントによって「なぜrefなのか」を明示できる構造であれば、レビューアーとしても合意形成がしやすくなります。

結論:状態を閉じ込めた構造は“追えない責務”になる

useRefに状態を閉じ込める構造は、開発者の意図を隠し、Reactの再レンダリングと整合しない構造を生みます。レビューアーは、次の点を見逃さないようにしてください。

  • UIに反映されない状態がrefに存在しないか
  • 副作用や非同期処理の制御がref依存になっていないか
  • 状態変更をトリガーに他の処理が走る構造になっていないか
  • テスト・保守性・責務の見通しが失われていないか

見えない状態がUIの中で勝手に動いていないか。その一点を確認することが、useRef の正しいレビューの第一歩です。