React Native で ISO8601 形式のUTC日付文字列からローカライズされた時刻(例:JST)を取得する

@react-native-community 謹製の DateTimePicker を使うと、 onChange 経由で渡される時刻が UTC(協定世界時) である、という問題にぶち当たります。

具体的には、この記事を書いている現在、6月24日(土)の 1:06 なのですが、 DateTimePicker から渡される日付は 2023-06-22T16:06:00.000Z になってしまいます。日本からすると 9時間前になるわけです。

多言語、多国籍で使って貰う場合は、端末のタイムゾーンに合わせて時刻を変換したほうが都合が良いでしょう。

端末から動的にタイムゾーンを取得して、タイムゾーンに合わせた時刻に変換する処理を追加してみます。

moment-timezone をインストール

shell
 npm i moment-timezone

2023年現在、 moment は推奨されない状況ではありますが、ただでさえ不安定な React Native + Expo の関係であるのと、タイムゾーンの変換がメインとなるアプリではなく、ここで冒険するメリットはないため、事例が豊富な moment-timezone を使います。

タイムゾーンに合わせて時刻を変換する

以下のようにタイムゾーンを取得します。

TypeScript.ts
 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

timeUtil.ts のようなファイルを作成して、以下の関数を作ってください。

timeUtil.ts
import moment from 'moment-timezone';

export function convertUTCToLocalISOString(isoDate: string, timezone: string): string {
  return moment(isoDate).tz(timezone).format('YYYY-MM-DDTHH:mm:ss');
}

DateTimePicker は以下です。

DateTimePicker.tsx
      <DateTimePicker
        testID="dateTimePickerStart"
        value={registerDate}
        mode="date"
        display="default"
        onChange={onRegisterDateChange}
      />

onChange={onRegisterDateChange} で渡された event を処理する関数は以下です。

sample.ts
  const onRegisterDateChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
    const currentDate = selectedDate !== undefined ? selectedDate : registerDate;
    const localDateString = convertUTCToLocalISOString(currentDate.toISOString(), timezone);
    console.log(
      `元の時刻 -> 変換した時刻(timezone) ${currentDate.toISOString()} -> ${localDateString}(${timezone})`
    );
    setRegisterDate(currentDate);
  };

コンソールには以下のように変換された内容が表示されます。

shell
元の時刻 -> 変換した時刻(timezone) 2023-06-22T16:33:00.000Z -> 2023-06-23T01:33:00(Asia/Tokyo)

コンポーネント全体は以下のとおりです。あくまでサンプルですが、全体像が伝わればと思います。

Sample.tsx
import { convertUTCToLocalISOString } from '../../lib/utils/timeUtil';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import React, { useState } from 'react';
import Dialog from 'react-native-dialog';

interface DocumentingDialogProps {
  documentingDialogVisible: boolean;
  setDocumentingDialogVisible: React.Dispatch<React.SetStateAction<boolean>>;
}

export default function DocumentingDialog({
  documentingDialogVisible,
  setDocumentingDialogVisible,
}: DocumentingDialogProps) {
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const [registerDate, setRegisterDate] = useState<Date>(new Date());
  const [sampleLocal, setSampleLocal] = useState<string>('');
  const onRegisterDateChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
    const currentDate = selectedDate !== undefined ? selectedDate : registerDate;
    const localDateString = convertUTCToLocalISOString(currentDate.toISOString(), timezone);
    console.log(
      `元の時刻 -> 変換した時刻(timezone) ${currentDate.toISOString()} -> ${localDateString}(${timezone})`
    );
    setSampleLocal(localDateString);
    setRegisterDate(currentDate);
  };
  return (
    <Dialog.Container visible={documentingDialogVisible}>
      <Dialog.Title>登録</Dialog.Title>
      <Dialog.Description>{sampleLocal}</Dialog.Description>
      <DateTimePicker
        testID="dateTimePickerStart"
        value={registerDate}
        mode="date"
        display="default"
        onChange={onRegisterDateChange}
      />
      <Dialog.Button
        label="Cancel"
        onPress={() => {
          setDocumentingDialogVisible(false);
        }}
      />
      <Dialog.Button
        label="OK"
        onPress={async () => {
          // await documentResults(tasks);
          setDocumentingDialogVisible(false);
        }}
      />
    </Dialog.Container>
  );
}

iOSの端末にインストールして動作することも確認できました。