React の SSG サイトでタイムゾーンを扱うときの罠と解決策
GatsbyのGraphQLで formatString
を用いて時刻を扱うとユーザーのタイムゾーンによっては日時が正しく表示されません。タイムゾーンを扱いながら正しい日付を表示する方法を解説します。
Gatsbyを使用した例を紹介しますが、それ以外のReactフレームワークでも同様の対処方法が使用できます。
(Gatsbyはv5を使用しています)
背景
Gatsby製のポートフォリオサイトの個人ブログでは、作成日時・更新日時を取り扱っていているのですが、タイムゾーンの考慮に苦労しました。
例えば、次のようなスキーマを想定してみます。
type BlogPost {
created: DateTime! # "1960-01-01T00:00+00:00"
}
query Query {
allBlogPost: [BlogPost!]!
}
Gatsby の GraphQL でフォーマットしてみる
まず、GatsbyのGraphQLでDateTimeを扱う際、 formatString
という便利関数が使用できることを知り、使ってみることにしました。
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で確認)
このような便利関数を作ってみました。
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を抑制できます。
ビルドされた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で確認)
先程の便利関数を次のように書き換えてみました。
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のブログを作りたいと考えている方は、私の個人ブログの実装をぜひ参考にしてみてください。
(おまけ) タイムゾーンを変更して単体テスト
単体テストでタイムゾーンを固定したい場合、 process.env.TZ
を変更すれば良いのではと考えてしまいがちなのですが、 nodeのプロセス実行前にタイムゾーンを設定しなければ動作しないようです。
そのため、タスクランナーで環境変数 TZ
を設定した上でjestを呼び出す方法を取る必要があります。
(特定のプラットフォームに依存しないように cross-env を使用しています。)
"scripts": {
"test": "jest",
"test:vietnam": "cross-env TZ=\"Asia/Ho_Chi_Minh\" npm run test"
},
Discussion