タイトルの通りです。
Hydration mismatch
ReactなどでSSRを伴う開発をしていれば一度は遭遇したことがあるのではないでしょうか。
SSRでレンダリングされたHTMLと、それを読み込んだクライアントが同様にレンダリングしたHTML(相当)に差異が生じた際、console上に記録されるエラーです。
例えば、以下のようなケースです。
export function App() {
const now = new Date().toLocaleString();
return <p>now: {now}</p>
}
少し露骨ではありますが、サーバーでレンダリングされる瞬間と、クライアントでハイドレーションされる瞬間は多くの場合で一致しないので now
の値が違うぞ、となるわけです。
回避にuseEffectを使う
SSR時は仮の値を表示しておいて、useEffectでマウント時に値を設定することで差異の発生を防ぐことができます。
import { useEffect, useState } from 'react';
export function App() {
const [now, setNow] = useState('SSR!'); // SSR/Hydration用の仮の値(?)
useEffect(() => {
setNow(new Date().toLocaleString());
}, []);
return <p>now: {now}</p>
}
このようにすることで、SSR時とハイドレーション時は 'SSR!'
を使い、マウントされたら実際の時間を表示することができます。
時間の概念は関数コンポーネントとしてEffectではあるので、それほど問題がないように見えます。
useEffectを使う問題点
ボタンを押してから表示されるコンポーネントなど、SSRしないケースを考えると無駄があることに気づきます。SSRしない場合、ハイドレーション差異を気にしなくていいので最初から new Date().toLocaleString()
が表示されてもいいはずです。
しかしuseEffectを使ってしまうと、その必要がなくても最初のレンダリングで 'SSR!'
が使用されることになります。これはちらつきの原因にもなります。
回避にuseSyncExternalStoreを使う
useSyncExternalStoreはReact 18で追加されたHookです。useSyncExternalStoreはuseEffectに比べインターフェースが難解ですが、唯一無二とも言える強力な機能を持っています。
詳細は公式ドキュメントに任せ、先の問題の回避策を確認します。
import { useSyncExternalStore } from 'react';
export function App() {
const now = useSyncExternalStore(
() => () => {},
() => new Date().toLocaleString(),
() => 'SSR!', // SSR/Hydration用の仮の値
);
return <p>now: {now}</p>
}
特徴は第三引数で、ここで渡した関数はSSR時とハイドレーション時にのみ利用されます。これは他のHookにはないような機能です。
SSR時、ハイドレーション時以外は第二引数の関数が利用されます。
useEffectで問題に挙げたような、「ボタンを押してから表示されるコンポーネント」では第三引数に渡した関数は呼ばれることなく、第二引数の関数の返り値が最初から利用(表示)されることになるわけです。
後方互換性のためのReact 17向け use-sync-external-store というパッケージが存在しますが、このパッケージのuseSyncExternalStoreは第三引数をサポートしていないので、今回の話は関係ありません。
useSyncExternalStoreの注意点
コンポーネントがレンダリングされる際に、第二引数の関数が実行されます。このときの返り値が前回の値と異なる場合、再度レンダリングを試みます。
つまり、第二引数の返り値が毎回変化するとレンダリングの無限ループに陥ります。
先の例では、 new Date().toLocaleString()
を使用しているため、レンダリング毎に値が変わる可能性は低いですが、例えば new Date().toISOString()
を使用した場合はミリ秒まで結果に含まれるので高い確率でレンダリング毎に値が変わり得ます。同様に new Date()
を使用するとプリミティブ値ではないため値が変化したものとして扱われます。
このように毎回変化しうる値を返り値として利用したい場合は、使用する際に値を適切にキャッシュする必要があります。
useRefでキャッシュする例:
import { useRef, useSyncExternalStore } from 'react';
export function App() {
const nowRef = useRef();
const now = useSyncExternalStore(
() => () => {},
() => {
if (nowRef.current === undefined) nowRef.current = new Date();
return nowRef.current;
},
() => new Date(0), // SSR/Hydration用の仮の値
);
return <p>now: {now.toISOString()}</p>
}
おわりに
useSyncExternalStore
は比較的新しいHookですが、非常に強力で唯一無二とも言える機能(SSR/Hydration用の処理)を持っています。実装された当初は「ライブラリ作者向け」というような注釈がありましたが、現在ではそのような記載はなくなりより一般的なHookとして名を連ねています。
Hydration mismatch以外でもさまざまな用途で利用できるので公式ドキュメントを確認してください。