useRefとuseStateを描画回数の違いで使い分ける方法

​​React​TypeScript,useRef,useState,タイマー

useState の値を変えると、React はコンポーネントを描画し直します。useRef の current を変えても、描画は起きません。この違いから、画面に出す値は useState に、画面に出さずに覚えておきたいだけの値は useRef に入れる、という使い分けが決まります。

筆者は検証環境で、描画のたびに記録を1行残すコンポーネントを作り、クリックの回数とタイマーの ID を題材に、描画の回数を数えて比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。StrictMode は、最後の節を除いて使っていません。GIF は、Chromium 141 を Playwright 1.56.1 で操作して撮りました。

ポイント

画面に出す値は useState に入れます。タイマーの ID のように、イベントハンドラで使うだけで画面には出さない値は useRef に入れます。ふつうの変数(let)は描画のたびに作り直されるので、描画をまたいで値を覚えておく用途には使えません。

描画の回数を数える方法

検証では、コンポーネントの関数の先頭で log を呼び、描画のたびに1行を記録しました。log は、文字列を配列にためるだけの検証用の関数です。StrictMode を使っていない開発用のビルドでは、この行数がそのまま描画の回数になります。

tsx
// クリックの回数を state に入れる
export function StateCounter() {
  const [count, setCount] = useState(0);
  log(`描画 StateCounter count=${count}`);

  return (
    <div>
      <p className="value">表示:{count}回</p>
      <button className="plus" onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

回数を useRef に入れると、画面が変わらない

同じカウンターを useRef で書いたのが次の RefCounter です。これは、画面に出す値を ref に入れてしまった、よくない例です。描画を起こすためだけに、別の state を更新するボタンも置いています。

tsx
// クリックの回数を ref に入れる(画面に出す値を ref に入れた、よくない例)
export function RefCounter() {
  const countRef = useRef(0);
  const [, setTick] = useState(0);
  log(`描画 RefCounter countRef.current=${countRef.current}`);

  return (
    <div>
      <p className="value">表示:{countRef.current}回</p>
      <button
        className="plus"
        onClick={() => {
          countRef.current = countRef.current + 1;
          log(`クリック後 countRef.current=${countRef.current}`);
        }}
      >
        +1
      </button>
      <button className="rerender" onClick={() => setTick((t) => t + 1)}>
        別の state を更新
      </button>
    </div>
  );
}

検証環境(jsdom)で、2つのコンポーネントの「+1」を3回ずつ押し、最後に RefCounter の「別の state を更新」を押しました。

text
--- はじめの描画
描画 StateCounter count=0
描画 RefCounter countRef.current=0
--- StateCounter の +1 を3回クリック
描画 StateCounter count=1
描画 StateCounter count=2
描画 StateCounter count=3
画面 StateCounter: 表示:3回
--- RefCounter の +1 を3回クリック
クリック後 countRef.current=1
クリック後 countRef.current=2
クリック後 countRef.current=3
画面 RefCounter: 表示:0回
--- RefCounter の「別の state を更新」をクリック
描画 RefCounter countRef.current=3
画面 RefCounter: 表示:3回

StateCounter は、クリックのたびに1回ずつ描画され、画面も3回になりました。RefCounter は、countRef.current が3まで増えたのに、描画は一度も起きず、画面は0回のままでした。別の state を更新して描画が起きたところで、ようやく3回と表示されました。

Chromium で同じ操作をした様子が、次の GIF です。描画回数と countRef.current の行は、撮影用のスクリプトが記録から数えて、React の外から書き込んでいます。

上の useState 版は、+1 を押すたびに表示と描画回数が増える。下の useRef 版は、+1 を3回押しても countRef.current だけが増えて表示は0回のまま。別の state を更新すると、表示が3回に変わり、描画回数が2になる
+1 と「別の state を更新」を押したときの、表示と描画回数の変わり方(上が useState 版、下が useRef 版)

ref を変えても描画が起きないことは、公式リファレンスの useRef の注意点にも書かれています。React は ref.current がいつ変わったかを知らないからです。同じ注意点には、初期化を除いて、描画の途中で ref.current を読み書きしないことも書かれています。上の RefCounter は描画の途中で countRef.current を読んでいるため、いつ表示が変わるかが、ほかの state の更新に左右されてしまいました。画面に出す値は useState に入れます。

タイマーの ID は useRef に入れる

ref が向いているのは、画面には出さずに、イベントハンドラで使うだけの値です。公式の学習ページにある、ref を使う場面の一覧でも、最初の例はタイマーの ID です。

ここでは、100ms ごとに数を1つ増やすストップウォッチを、タイマーの ID の置き場所だけを変えて3通り書きました。経過を表す ticks は画面に出すので、3つとも useState です。useRef 版は次のとおりです。

tsx
// タイマーの ID を ref に入れる
export function StopwatchRef() {
  const [ticks, setTicks] = useState(0);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  log(`描画 ref ticks=${ticks}`);

  function handleStart() {
    intervalRef.current = setInterval(() => setTicks((t) => t + 1), TICK_MS);
  }
  function handleStop() {
    log(`停止 intervalRef.current=${intervalRef.current === null ? 'null' : 'ID あり'}`);
    if (intervalRef.current !== null) clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  return <Buttons ticks={ticks} onStart={handleStart} onStop={handleStop} />;
}

ほかの2つは、ID をふつうの変数(let intervalId)に入れる版と、state(intervalId)に入れる版です。どれも「開始」を押して 20ms 待ってから、さらに 450ms 待ちました。続けて「停止」を押して 20ms 待ってから、さらに 450ms 待ちました。タイマーで起きる描画を1回ずつ数えるため、この検証ではテスト用の act を使わずに、実際の時間を待っています。

text
=== タイマーの ID を let に入れる
はじめの描画: 描画 1回
開始をクリック: 描画 0回
450ms 待つ: 描画 4回 表示=4
停止をクリック: 停止 intervalId=undefined 描画 0回
さらに 450ms 待つ: 描画 5回 表示=9
=== タイマーの ID を state に入れる
はじめの描画: 描画 1回
開始をクリック: 描画 1回
450ms 待つ: 描画 4回 表示=4
停止をクリック: 停止 intervalId=ID あり 描画 1回
さらに 450ms 待つ: 描画 0回 表示=4
=== タイマーの ID を ref に入れる
はじめの描画: 描画 1回
開始をクリック: 描画 0回
450ms 待つ: 描画 4回 表示=4
停止をクリック: 停止 intervalRef.current=ID あり 描画 0回
さらに 450ms 待つ: 描画 0回 表示=4

結果を表にまとめます。

ID の置き場所 開始のクリックでの描画 停止のクリックでの描画 止められたか
let 0回 0回 できない(表示が4から9に進んだ)
useState 1回 1回 できた
useRef 0回 0回 できた

let 版は、停止を押しても止まりませんでした。handleStop が読んだ intervalId は undefined でした。ふつうの変数は描画のたびに作り直されます。タイマーで描画し直すと、新しい描画で作られた handleStop は、ID が入っていない新しい intervalId を読みます。

useState 版は止まりますが、開始と停止のクリックでそれぞれ1回、画面に何も変化がない描画が増えました。useRef 版は、止まるうえに余分な描画もありません。公式リファレンスの、ref で値を参照する使い方の説明にも、ref の値は描画をまたいで残り、変えても描画が起きないと書かれています。

更新した直後に読むと、state と ref で値が違う

イベントハンドラの中で、更新した直後に値を読むと、state と ref で結果が違いました。次のハンドラを付けたボタンを、1回押しました。

tsx
function handleClick() {
  setCount(count + 1);
  countRef.current = countRef.current + 1;
  log(`直後に読む count=${count} countRef.current=${countRef.current}`);
  setTimeout(() => {
    log(`500ms 後に読む count=${count} countRef.current=${countRef.current}`);
  }, 500);
}
text
直後に読む count=0 countRef.current=1
500ms 後に読む count=0 countRef.current=1

count は、500ms 後に読んでも0のままでした。公式の学習ページの、スナップショットとしての state の説明のとおり、ハンドラは、そのハンドラを作った描画のときの state を見るからです。countRef.current は、書き換えた直後から1でした。最新の値をハンドラの中で読みたいだけなら ref が使えますが、その値を画面に出すなら state に入れます。

StrictMode では描画の記録が2倍になる

描画の回数を数えるときは、StrictMode に気をつけます。StateCounter を StrictMode で囲み、開発用のビルドで動かすと、記録は次のようになりました。

text
--- はじめの描画
描画 StateCounter count=0
描画 StateCounter count=0
--- +1 を1回クリック
描画 StateCounter count=1
描画 StateCounter count=1

はじめの描画も、クリック1回ぶんの描画も、記録が2行ずつ残りました。公式リファレンスの useRef の注意点によると、StrictMode ではコンポーネントの関数が2回呼ばれ、これは開発のときだけの動きです。本番用のビルドは、この記事では試していません。

​Posted by 86