こんにちは。最近、Reactでのステート管理において「useStateの中にステートを置くのではなく、useRefで得たrefオブジェクトの中にステートを置いてuseState(またはuseReducer)をコンポーネントの再レンダリングを発生させるためだけに使う」というやり方を複数の記事で見かけました。このパターンは、今(React 17以前)は動くけどReact 18でアンチパターンに変貌するやり方なので、啓蒙するためにこの記事を用意しました。
ステート(コンポーネントのレンダリングに使用される値)は、useRefではなくuseState(またはuseReducer)をちゃんと使って管理するようにすれば、React 18以降も安泰です。
useRefをステート管理に使うパターンとは
こういうやつです。
// 普通のやり方
const Counter1: React.VFC = () => {
const [counter, setCounter] = useState(0);
return (
<div>
<p>Counter is {counter}</p>
<p>
<button
onClick={() => {
setCounter((c) => c + 1);
}}
>
increment
</button>
</p>
</div>
);
};
// useRefを使うやり方
const Counter2: React.VFC = () => {
const counter = useRef(0);
const [, forceRerender] = useReducer((c: number) => c + 1, 0);
return (
<div>
<p>Counter is {counter.current}</p>
<p>
<button
onClick={() => {
counter.current++;
forceRerender();
}}
>
increment
</button>
</p>
</div>
);
};
ここで定義されているコンポーネントはどちらも「ボタンを押すと表示されている数字が増える」というものですが、この数字はCounter1
ではuseStateの中にある一方で、Counter2
ではuseRefの中で管理されています。Counter2
ではrefの中身(counter.current
)を更新しただけではコンポーネントが再レンダリングされない(新しい状態が画面に反映されない)ので、useReducerを用いて「呼ぶと再レンダリングされる関数」(forceRerender
)を作っておき、refの中身の更新後にforceRerender
を呼び出すことでuseStateと同じ挙動を実現しています。
後者のように、「ref.current
をレンダリング結果(関数コンポーネントの返り値)に使用している」ケースがこの記事でいう「useRefでステート管理」に該当します。
無意味にこのようなやり方をする人はいないと思いますが、諸々の事情があってやりたくなるケースがあるようです。
なぜReact 18でアンチパターンになるのか
useRef
を使うやり方は、React 18で追加されるトランジションの機能と組み合わせて使うと壊れるからです。
壊れる例をこちらに用意しました。
開くと、このようなアプリが表示されるはずです。
初期状態では「0 × 1000 = 0」と表示された部分と、incrementボタンのセットが2つ用意されています。左側の「0」部分がステートであり、上はuseStateで、下はuseRefで管理されています。上と下の実装は、ステートをどのように管理するかという点以外はまったく同じです。
トランジションはReactのSuspense機能と関連するものであり、ざっくり言うとステートの更新によって得られる新しいレンダリング結果が表示できるまでに時間がかかる場合(レンダリングがサスペンドする場合)において、新しいレンダリング結果が届くまでの間ステート更新前の画面を表示したままにしておける機能です。
今回は「× 1000」の計算に1秒かかるという設定になっています。useStateの方で「increment」ボタンを押すと表示が半透明になります(計算中を表しているつもり)。
そして、計算が完了する1秒後に「1 × 1000 = 1000」という表示になります。
コードを見ると分かりますが、ステート(counter1
)をインクリメントするのは、ボタンを押すとすぐに実行しています(ただし、トランジションの中で)。それにもかかわらずそれが画面に反映されるのが1秒後まで遅らせられるというのがトランジションの効果です。トランジションを使うことで、時間がかかる処理においてもスムーズな画面遷移をユーザーに見せることができます。
一方で、useRefを使った実装の場合、「increment」ボタンを押すと「Loading...」表示に切り替わります。
これはトランジションが効いておらず、コンポーネントがサスペンドしたことによるフォールバック指定(<Suspense>
のfallback
propに指定したもの)が表示されているからです。
このように、useRefを用いてステート管理したものについては、トランジションの恩恵をうまく受けることができないのです。これは、useRefの中に入ったものはReactに「ステートである」と認識されていないからです。
まとめ
useRefの中にステートを置いてuseStateまたはuseReducerを用いてコンポーネントを強制的に再レンダリングさせることでステート管理するというやり方は、このようにReact 18で導入されるトランジションと相性が悪いためアンチパターンとなります。
従来(React 17まで)は確かにuseStateは「useRef + 再レンダリング機能」と見なせるような機能でしたが、React 18からは「これに入っているものはステートである」と宣言しReactに伝える役割を持つようになります。トランジションは「ステートの更新」をトラックすることで動作するため、useRefの中に入っているものはReactが認識する「ステート」に含まれず、useRefではuseStateの機能を代替できなくなるのです。
じゃあトランジションなんか使わなければいいじゃんと思われるかもしれませんが、実際のところほぼ全てのステート更新は本来トランジションとして扱われるべきものです(出典)。トランジションにできないステート更新のほうが例外的なのです。よって、トランジションに対応したステート管理方法でないと、React 18以降とてもやっていけないということになります。
ですから、近く来るReact 18に備えて知識をアップデートし、useRefでステートを管理するのはアンチパターンだと覚えて帰ってくださいね。
Q&A
Q. トランジションとかサスペンドとか言われても全然分からないし、サンプルコードも読めないんだけど。
A. 確かに多少前提知識が必要でしたね。しかしご安心ください。筆者がこれまでに書いた以下の記事およびZenn本を順番に読めばすべて理解できます。今すべて読む時間がないとしても、何がアンチパターンなのかということだけは理解して帰ってください。
Q. でもReduxとか他のステート管理ライブラリもuseStateを再レンダリングのためだけに使ってるじゃん!
A. お詳しいですね。確かにReduxなどもuseStateなどを使わずにReactのコンポーネントツリーの外部でステート管理をしているという点で、useRefによるステート管理に近いものです(むしろ、それをうまくやってくれることこそがステート管理ライブラリの価値であると言っても過言ではないでしょう)。実際、react-reduxの現行バージョンのソースコードを見ると、forceRender
のためにuseReducerを使っているコードがあります。しかし、まさにそのためにReduxもトランジションに対応できませんでした。Reduxもアンチパターンを踏んでいたのです(React 17以前では他にやりようがなく仕方なかったのですが)。ただし、だからReact 18ではReduxが使えないというわけではなく、useSyncExternalStore
という新しい機能がReact 18で追加されるのでこれを用いてReact 18に対応しています。もしどうしてもuseRefによるステート管理が必要なら、もしかしたらuseSyncExternalStore
を調べてみると幸せになれるかもしれません。