Notes

hover 遅延フックで useRef を選んだ理由 — 描画しない値を state に入れない

リストの hover を 300ms 遅らせて切り替えるフックを書いた。中で持つ timer ID と hover 中 index を useState ではなく useRef にした判断を、再レンダーのコストと stale closure の観点から整理する。「値を覚えたい」と「画面を更新したい」は別の要求だという話。

#React#TypeScript#React Hooks#useRef#パフォーマンス

リストの hover でアクティブな item を切り替える UI を作った。ただし即座には切り替えない。300ms 留まったら採用する。マウスが素早く横切るときに途中の item を全部アクティブにするとチラつくからだ。

その useHoverDelay の中で、状態を 2 つ持っている。

// useHoverDelay.ts(抜粋)
const timerRef = useRef<number>(0)
const hoveredIndexRef = useRef<number | null>(null)

どちらも useState ではなく useRef にした。React で「値を覚えておきたい」とき最初に浮かぶのは useState だが、必要だからといって全部 state にすると、意図しない再レンダーとイベント処理のズレが出る。この 2 行がなぜ useRef であるべきかを整理する。

このフックが解いている問題

UI 仕様はシンプルだ。

イベント期待する挙動
別の item に mouseenter進行中の timer を消し、300ms 後に「この index を採用する」
同じ item に再度 mouseenter何もしない
mouseleavetimer だけ消す。採用済みの index は変えない

肝は leave の行だ。leave しても表示は戻さない。timer を捨てるだけで、すでに切り替わったアクティブ表示はそのまま残す。

つまり「待っている最中の情報」は、画面に出す値ではない。画面に出すのは遅延後にコールバックで渡す index だけだ。フックが内部で持つべきなのは 2 つに絞られる。

どちらも レンダー結果を変えずに、次のイベントで読み書きしたい値 だ。ここに useRef が噛む。

useRef は再レンダーしない箱

useRef は、コンポーネントの寿命のあいだ生き残る可変の箱を返す。.current を書き換えても React は再描画しない。

useStateuseRef
値の更新setter を呼ぶ.current に代入する
更新後の再レンダー起きる起きない
向いているもの画面に出る状態、派生計算の入力DOM 参照、timer ID、前回値、イベント用のフラグ

逆に言えば、描画に使わない値を useState に入れると、ロジック上どうでもいい更新のたびに描画が走る。今回の 2 つの ref は、どちらも「描画に使わないが、次のイベントで最新値が要る」パターンだ。

timerRef:timer ID は画面の状態ではない

setTimeout は数値の ID を返す。あとから clearTimeout するには、その ID をどこかに置いておく必要がある。

// useHoverDelay.ts(抜粋)— timer のセットと破棄
const clearTimer = useCallback(() => {
  if (timerRef.current !== 0) {
    clearTimeout(timerRef.current)
    timerRef.current = 0
  }
}, [])
 
timerRef.current = window.setTimeout(() => {
  timerRef.current = 0
  onDelayed(index)
}, delayMs)

この ID はユーザーに見せる数字でも CSS を切り替えるフラグでもない。次の enter / leave / unmount で「前の予約を取り消す」ためのハンドルだ。

useState にすると何が起きるか

// アンチパターン: timer ID を state にする
const [timerId, setTimerId] = useState(0)
 
setTimerId(window.setTimeout(() => { /* ... */ }, delayMs))

enter のたびに state が更新され、フックを使うコンポーネントが再レンダーする。しかもその再レンダーは UI を何も変えない。timer ID は JSX に出てこないからだ。見えない値のために描画コストだけ払うことになる。hover は高頻度イベントなので、この無駄は無視できない。

加えて setState は非同期だ。同じイベントハンドラの直後に timerId を読んでも古い値のことがある。clearTimeout のような「今この瞬間の ID を消す」処理とは相性が悪い。ref.current は代入した直後から読める。

ただの変数では足りない

// これも壊れる: レンダーのたびに 0 に戻る
let timerId = 0

レンダーのたびにこの行が再実行され、前回セットした timeout の ID は消える。結果 clearTimeout できず、遅延コールバックが多重発火する。

かといってモジュールスコープに置くと、フックのインスタンスが複数あるとき(カルーセルが 2 つある、など)に ID が共有されて壊れる。

useRef は「このフック呼び出し専用の、レンダーをまたぐ可変スロット」である。インスタンスローカルであることと、レンダーをまたいで生き残ることの両方が要るなら、これしかない。

hoveredIndexRef:判定用の値も描画用ではない

handleMouseEnter の先頭で、同じ index への再 enter を捨てている。

// useHoverDelay.ts(抜粋)— 同一 index は no-op
if (hoveredIndexRef.current === index) {
  return
}
hoveredIndexRef.current = index

leave では null に戻す。この値の役割は「いまポインタが乗っているとフックが認識している item」であって、画面の「いまアクティブなスライド」ではない。アクティブ側は遅延後の onDelayed(index) が親の state を更新して決まる。

仕様上 leave してもアクティブは維持するので、「hover 中の index」と「表示中の index」は最初から別物だ。前者を state にすると 2 つの問題が出る。

// アンチパターン: hover 中 index を state にする
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
 
const handleMouseEnter = useCallback((index: number) => {
  if (hoveredIndex === index) return
  setHoveredIndex(index)
  // timer セット
}, [hoveredIndex])

1. 描画に使わない更新で再レンダーする

item を渡り歩くたびに hoveredIndex が変わって再レンダーする。この値を JSX に出していなければユーザーから見える変化はない。300ms 待っているあいだに何度も描画が走るだけだ。

2. useCallback の安定性と、比較の正しさが両立しない

依存配列に hoveredIndex を入れると hover のたびに関数が作り直される。外すとクロージャが古い hoveredIndex を掴んだままになり、「同じ index なら no-op」が壊れる。どちらを選んでも「ハンドラは安定、比較は常に最新」が崩れる。

ref なら関数を作り直す必要がない。.current は常に最新で、依存配列に ref オブジェクトを書く必要もない。

このフックは handleMouseEnter / handleMouseLeave を子の onMouseEnter に渡す想定だ。ハンドラが毎回新しい参照になると、メモ化した子まで再レンダーする。遅延ロジックの内部事情でリスト全体が再描画されるのは本末転倒だ。

2 つの ref が分担しているもの

ref覚えているもの読み書きのタイミング画面への影響
timerRefsetTimeout の IDenter でセット、leave / 次の enter / unmount で clearなし
hoveredIndexRefいま hover 中の indexenter で更新、leave で nullなし

画面を変える唯一の経路は onDelayed だ。親がそこで初めて「表示用の index」を state に載せる。フック内の 2 値は、その発火条件を正しくするための制御用メモリにすぎない。

この分離のおかげで「leave しても active は変えない」が素直に実装できる。hover 中フラグを state にして画面と結びつけると、「leave で null にした瞬間に表示が戻る」という別の設計に引きずられやすい。

これは debounce ではない

見た目は「最後に留まった item だけ採用」で debounce に近いが、待ち方とキャンセルの単位が違う。

同じ item に居続けるあいだ timer は 1 本のままだ(同一 index は no-op なので触らない)。別 item に移った瞬間だけ古い予約を破棄する。だから「最後の hover だけが採用される」ように見えるが、実装の単位は index ごとの単発予約 だ。

この動きを支えているのが timerRef の「今動いている予約は常に 1 つ」と hoveredIndexRef の「今どの index 用の予約を置いたか」で、どちらも連続したイベント列の途中状態であり、フレームごとの描画状態ではない。

採用しなかった代替案

useState で両方持つ。 動くしテストも書ける。ただし enter / leave のたびに再レンダーし、ハンドラの依存配列が膨らみ、「表示用 index」と「hover 判定用 index」が混ざりやすい。コストに対して得るものがない。

ref を 1 本にまとめる。

// 技術的には問題ないが、今回は分けた
const stateRef = useRef({ timer: 0, hoveredIndex: null as number | null })

寿命も更新タイミングも違う 2 値なので分けた方が読みやすい。timer は「予約のハンドル」、hoveredIndex は「同一性の判定」で、役割が別だ。

フックの外に変数を置く。 インスタンスをまたいで壊れる。React のフックは「呼び出しごとに状態を持つ」前提なので、インスタンスローカルな箱として useRef を使うのが正しい階層になる。

判断のチェックリスト

値が必要になったとき、次を順に見ると選びやすい。

  1. その値を JSX や、他の state の計算に使うか → Yes なら useState(派生なら useMemo)
  2. DOM ノードを掴みたいだけか → Yes なら useRef(本来の用途)
  3. イベント・timer・購読の途中状態で、更新しても描画を変えなくてよいか → Yes なら useRef
  4. 最新値を安定したコールバックから読みたいか → Yes なら useRef(stale closure を避ける定番)

今回の 2 行は 3 と 4 に同時に当てはまる。timer ID は描画に使わないが clear のために常に最新が要る。hover 中 index は描画に使わないが、同一 index 判定と安定した useCallback のために常に最新が要る。

実装全体

ref を「制御プレーン」、コールバックを「表示プレーン」に分けた形になっている。

// useHoverDelay.ts
const useHoverDelay = ({ onDelayed, delayMs = 300 }) => {
  const timerRef = useRef<number>(0)
  const hoveredIndexRef = useRef<number | null>(null)
 
  const clearTimer = useCallback(() => {
    if (timerRef.current !== 0) {
      clearTimeout(timerRef.current)
      timerRef.current = 0
    }
  }, [])
 
  const handleMouseEnter = useCallback(
    (index: number) => {
      if (hoveredIndexRef.current === index) return
      hoveredIndexRef.current = index
      clearTimer()
      timerRef.current = window.setTimeout(() => {
        timerRef.current = 0
        onDelayed(index)
      }, delayMs)
    },
    [onDelayed, delayMs, clearTimer],
  )
 
  const handleMouseLeave = useCallback(() => {
    hoveredIndexRef.current = null
    clearTimer()
  }, [clearTimer])
 
  useEffect(() => clearTimer, [clearTimer])
 
  return { handleMouseEnter, handleMouseLeave }
}

「待っているあいだは静かに内部状態だけ進める。採用が決まった瞬間だけ画面を動かす」。その境界線を useRef が担っている。

補足: クロージャ

クロージャは、関数が作られた場所の変数を、あとからでも読める仕組みだ。

function makeCounter() {
  let count = 0
  return () => {
    count += 1
    return count
  }
}
 
const next = makeCounter()
next() // 1
next() // 2

next は makeCounter の外で呼ばれているが、内側の count を覚えている。関数と、その関数が参照している外側の変数がセットで残る。

React ではレンダーのたびに関数が新しく作られる。useCallback も同じで、依存配列の値が変わったときだけ新しい関数を作り、それ以外は前回の関数を使い回す。

// この hoveredIndex は「この関数が作られたときの値」
const handleMouseEnter = useCallback(
  (index: number) => {
    if (hoveredIndex === index) return
    setHoveredIndex(index)
  },
  [hoveredIndex],
)

依存配列を空にすると、初回レンダーの hoveredIndex(多くは null)を掴んだままになる。これが stale closure だ。

useRef が効くのは、クロージャが掴む対象が「値そのもの」ではなく「箱」だからだ。関数が古いままでも .current を読めば今の値が取れる。上で「依存に入れると関数が作り直される / 外すと比較が古くなる」と書いたのは、このクロージャの性質が原因だった。

教訓: 依存配列で悩んだら、その値が本当に描画に要るのかを先に疑う。

補足: debounce と throttle

debounce は、連続する入力のうち最後の 1 回だけを、静かになってから実行する技法だ。検索ボックスで 1 文字ごとに API を叩かないように「入力が止まってから 300ms 後に 1 回だけ」とするのが典型例。

入力:  a   ap   app   appl   apple
予約:  ●    ●    ●     ●      ●
実行:                            ★(最後の apple だけ)

似た技法の throttle は「N ms に 1 回は必ず実行する」で、スクロール位置の更新などに使う。debounce は「静かになるまで待って最後だけ」、throttle は「一定間隔で間引く」。

このフックが debounce に見えるのは、別 item へ素早く移動すると途中の予約が捨てられ、最後に留まった item だけが採用されるからだ。ただし実装の単位が違う。

debounceこのフック
何を待つか「入力が止まったこと」「同じ index に delayMs 留まったこと」
予約のキー入力列全体で timer 1 本index ごとの単発予約
同じ対象が続いたとき最後の入力から計時し直すtimer は触らない(同一 index は no-op)
leave遅延後に実行することが多いtimer を捨てて実行しない

検索の debounce なら apple と打つ過程で timer を毎回振り直す。このフックは同じ item の上に居続けるあいだ timer は 1 本のままだ。leave では「遅らせて実行」ではなく「実行しない」。だから debounce とは呼ばない。

まとめ