🕰

React の SSG サイトでタイムゾーンを扱うときの罠と解決策

2023/03/16に公開

GatsbyのGraphQLで formatString を用いて時刻を扱うとユーザーのタイムゾーンによっては日時が正しく表示されません。タイムゾーンを扱いながら正しい日付を表示する方法を解説します。

Gatsbyを使用した例を紹介しますが、それ以外のReactフレームワークでも同様の対処方法が使用できます。

(Gatsbyはv5を使用しています)

背景

Gatsby製のポートフォリオサイトの個人ブログでは、作成日時・更新日時を取り扱っていているのですが、タイムゾーンの考慮に苦労しました。

https://github.com/bicstone/portfolio

例えば、次のようなスキーマを想定してみます。

type BlogPost {
  created: DateTime! # "1960-01-01T00:00+00:00"
}

query Query {
  allBlogPost: [BlogPost!]!
}

Gatsby の GraphQL でフォーマットしてみる

まず、GatsbyのGraphQLでDateTimeを扱う際、 formatString という便利関数が使用できることを知り、使ってみることにしました。

https://www.gatsbyjs.com/docs/graphql-reference/

query Query {
  allBlogPost {
    a: created
    b: created(formatString: "YYYY-MM-DD HH:mm:ss")
  }
}

この結果、どうなると思いますか?

このようになります。

{
  "a": "1960-01-01T00:00+09:00",
  "b": "1959-12-31 15:00:00"
}

formatString は、ビルド環境やユーザー環境にかかわらず、 UTC の時間を返します。また、タイムゾーンの指定はできません。

GraphQLで完結するので一見便利そうですが、 UTCで問題がない場合以外には使えませんね。

タイムゾーン付きで取得し、JS でフォーマットしてみる

そこで、GraphQLにはデータの取得のみ任せて、JSでフォーマットすることにしました。

ライブラリとしてdate-fnsを使います。 (2.29.3で確認)

https://github.com/date-fns/date-fns

このような便利関数を作ってみました。

formatDateTime.ts
import { format as formatFn, isValid, parseISO } from "date-fns";

export const formatDateTime = (value: string, format: string): string => {
  const parsedDate = parseISO(value);

  if (!isValid(parsedDate)) return "";

  return formatFn(parsedDate, format);
};

ISO形式の日時を受け取り、ローカルのタイムゾーンでformatして返します。

formatDateTime(post.created, "yyyy/MM/dd HH:mm:ss"); // "1960/01/01 00:00:00"

この便利関数を通すことで、無事にユーザーのタイムゾーンに合わせた日時を表示できました。

しかし、ビルド環境とユーザーの閲覧環境でタイムゾーンが異なった場合、ビルドされたHTMLと整合性が取れずにhydration errorが発生してしまうという問題がありました。

hydration error を抑制してみる

そこで、まずはhydration errorを抑制する方法を考えました。

Reactでは、 suppressHydrationWarning を使用することで、 hydration errorを抑制できます。

https://beta.reactjs.org/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors

ビルドされたHTMLではnullを入れておき、クライアント側がレンダリングするタイミングでコンポーネントを返すようにします。

<div suppressHydrationWarning>
  {typeof window === "undefined" ? null : (
    <time dateTime={post.created}>{createdDate}</time>
  )}
</div>

hydration errorを抑制できました。しかし、この方法では、クライアントのhydrateが遅れた場合にコンポーネントが表示されないリスクを伴います。

ブログ上部に表示されるコンポーネントだったため、特にちらつきが気になってしまい、この方法は採用できませんでした。

タイムゾーンを固定してみる

当個人ブログは、JSTのタイムゾーンを前提として問題がないブログであるため、最終手段としてJSTで固定することにしました。

タイムゾーンを固定するライブラリとしてdate-fns-tzを使います。 (1.3.7で確認)

https://www.npmjs.com/package/date-fns-tz

先程の便利関数を次のように書き換えてみました。

formatDateTime.ts
import { isValid } from "date-fns";
import { format as formatFn, utcToZonedTime } from "date-fns-tz";
import { ja } from "date-fns/locale";

const timeZone = "Asia/Tokyo";

export const formatDateTime = (value: string, format: string): string => {
  const parsedDate = utcToZonedTime(value, timeZone);

  if (!isValid(parsedDate)) return "";

  return formatFn(parsedDate, format, { locale: ja, timeZone });
};

この方法では、ビルド環境に関わらず、 JSTでフォーマットされたHTMLが生成されます。エラー抑制をせずともhydration errorは発生せず、さらにhydrateが遅れてもコンポーネントが表示され、ちらつきが発生しません。

まとめ

タイムゾーンを持った日時を受け取り、もろもろ処理してクライアントの環境に依存せず、正しい日時を表示することが出来ました。タイムゾーンを持った日時を受け取ったら、何かしらの処理が必要なことを忘れないように気をつけます (自戒)

また、Gatsbyのブログを作りたいと考えている方は、私の個人ブログの実装をぜひ参考にしてみてください。

https://github.com/bicstone/portfolio

(おまけ) タイムゾーンを変更して単体テスト

単体テストでタイムゾーンを固定したい場合、 process.env.TZ を変更すれば良いのではと考えてしまいがちなのですが、 nodeのプロセス実行前にタイムゾーンを設定しなければ動作しないようです。

https://github.com/nodejs/node/issues/3449

そのため、タスクランナーで環境変数 TZ を設定した上でjestを呼び出す方法を取る必要があります。

(特定のプラットフォームに依存しないように cross-env を使用しています。)

package.json
  "scripts": {
    "test": "jest",
    "test:vietnam": "cross-env TZ=\"Asia/Ho_Chi_Minh\" npm run test"
  },

Discussion