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 を使っていない開発用のビルドでは、この行数がそのまま描画の回数になります。
// クリックの回数を 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 を更新するボタンも置いています。
// クリックの回数を 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 を更新」を押しました。
--- はじめの描画
描画 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 の外から書き込んでいます。

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 版は次のとおりです。
// タイマーの 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 を使わずに、実際の時間を待っています。
=== タイマーの 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回押しました。
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);
}
直後に読む 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 で囲み、開発用のビルドで動かすと、記録は次のようになりました。
--- はじめの描画
描画 StateCounter count=0
描画 StateCounter count=0
--- +1 を1回クリック
描画 StateCounter count=1
描画 StateCounter count=1
はじめの描画も、クリック1回ぶんの描画も、記録が2行ずつ残りました。公式リファレンスの useRef の注意点によると、StrictMode ではコンポーネントの関数が2回呼ばれ、これは開発のときだけの動きです。本番用のビルドは、この記事では試していません。





