Open9

ReactでDateに関してWarning: XXX did not match.になるとき

サーバーサイド(SSR/SG)とクライアントサイド(ブラウザ)でのレンダー結果が異なるエラー。

React Hydration Error
Why This Error Occurred
While rendering your application, there was a difference between the React tree that was pre-rendered (SSR/SSG) and the React tree that rendered during the first render in the Browser. The first render is called Hydration which is a feature of React.
https://nextjs.org/docs/messages/react-hydration-error

パフォーマンス的に問題があるらしい。
参考記事:
https://zenn.dev/aiji42/articles/e6f1a798e9b416

よくあるのはCSS関連ライブラリでclassNameが異なるみたいなやつらしいが、今回のケースでは Next.jsのdev serverのタイムゾーンとブラウザのタイムゾーンが異なっている ことによる問題だった。

例:JSTの2022/01/17 00:00を表示したいコンポーネント

日本時間のブラウザでレンダリング
<time datetime="2022-01-16T15:00:00.000Z">2022-01-17</time>
SSR/SGのレンダリング結果
<time datetime="2022-01-16T15:00:00.000Z">2022-01-16</time>

日本時間のクライアントしか考慮しない場合、Next.jsのdev serverをAsia/Tokyoタイムゾーンで起動してしまえば、一応エラーは消える。

package.json(抜粋)
{
  "scripts": {
    "dev": "TZ='Asia/Tokyo' next dev",
  },
}

しかし、今回はそもそも、日時はクライアントのマシンやブラウザ設定に合わせて表示されてほしい。(表示がクライアントの設定ごとに異なっていても意味論的に問題ないように、time要素のdatetime属性には必ずUTC時刻を入れてある。)

つまり「この部分はSSR/SGの対象外」になってほしい。

方法1

単一要素の属性やテキストコンテンツがサーバ・クライアント間においてやむを得ず異なってしまう場合(例えばタイムスタンプなど)、要素に suppressHydrationWarning={true} を追加することで警告の発生を停止させることが可能です。それは 1 階層下の要素までで機能するものであり、また避難ハッチとして使われるものです。そのため、多用しないでください。テキストコンテンツでない限り、React は修復を試行しようとはしないため、将来の更新まで不整合が残る可能性があります。
https://ja.reactjs.org/docs/react-dom.html#hydrate

今回はまさにこの例の通りの「やむを得ず異なってしまう場合」にあたると思う。例えばこのようにすると、エラーは出なくなる。

time.tsx(抜粋)
      <time dateTime={dt.toISOString()}>
        <span suppressHydrationWarning={true}>{yyyymmdd(dt)}</span>
      </time>

方法2

サーバとクライアントで異なるものをレンダーしたい場合は、2 パスレンダーを使用できます。クライアント側で異なるものをレンダーするコンポーネントでは、this.state.isClient のような state 変数を読み込み、componentDidMount()true を設定することができます。こうすると、最初のレンダーパスではサーバ側と同一の内容を描画して不一致を回避しますが、追加のパスが初回レンダーの直後に同期的に発生します。このアプローチでは 2 回レンダーが発生することによりコンポーネントのパフォーマンスが低下しますので、注意して使用してください。
https://ja.reactjs.org/docs/react-dom.html#hydrate

この方法で、コンポーネント内にisClientという表示制御用ステートを保持し、componentDidMountの代わりにuseEffectを使って実装すると、こんな感じになる。

time.tsx(抜粋)
  const [isClient, setIsClient] = useState<boolean>(false);
  useEffect(() => setIsClient(true), []);

  return (
    <time dateTime={dt.toISOString()}>{isClient ? hhhhmmdd(dt) : ''}</time>
  );

ちなみに、useEffectの第二引数に空の配列[]を渡してcomponentDidMountの代わりにするテクニックについては:

If you want to run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([]) as a second argument. This tells React that your effect doesn’t depend on any values from props or state, so it never needs to re-run. This isn’t handled as a special case — it follows directly from how the dependencies array always works.
If you pass an empty array ([]), the props and state inside the effect will always have their initial values. While passing [] as the second argument is closer to the familiar componentDidMount and componentWillUnmount mental model, there are usually better solutions to avoid re-running effects too often. Also, don’t forget that React defers running useEffect until after the browser has painted, so doing extra work is less of a problem.
https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

better solutions としてリンクされている文書を読むと、要するに「コンポーネントスコープの変数を使っていないeffectなら、依存関係リストに空の配列を渡しても安全」という話。

今回の使い方においては、初回にisClienttrueにしたら他の場所でisClientが変更されることもないため、特に問題ないと思う。

方法1は警告を黙らせるだけの処理なので、どちらかというと方法2の方が適切なように思う。

今回の例では「サーバーサイドでは値が表示されない」ものとしたが、例えばサーバーサイドではUTC時刻で表示され、クライアントサイドではクライアントの時刻が表示されるといった方法でもいいかもしれない。

time.tsx(抜粋)
  const [isClient, setIsClient] = useState<boolean>(false);
  useEffect(() => setIsClient(true), []);

  return (
    <time dateTime={dt.toISOString()}>{isClient ? hhhhmmdd(dt) : hhhhmmddUTC(dt)}</time>
  );

日付の表示ってありとあらゆるアプリにありがちだと思うんだけど、みんなどうしているのかな……?
お勧めのやり方があったら是非教えてください~!

ログインするとコメントできます