Reactで、開始日と終了日を選ぶ入力欄を作る方法

​​React​HTML,Playwright,TypeScript,フォーム

開始日と終了日を選ぶ入力欄は、<input type="date"> を2つ並べ、終了日の min に開始日の値を渡すと作れます。開始日より前の日はピッカーで灰色になり、キーボードで前の日を打っても、送信の前にブラウザが止めます。React 側で持つのは、2つの日付の文字列だけです。

ライブラリを使わずに DateRangeInput という部品を作り、キーボードでの入力、ピッカーの見え方、フォームの送信での動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。Chromium は日本語の設定(--lang=ja-JP)で起動し、入力欄は「yyyy/mm/dd」の形で表示されました。Playwright でピッカーの中の日付をクリックしても値は入らなかったため、ピッカーの見え方は、スクリーンショットに写った表示で確かめています。Firefox、Safari、スマートフォンのブラウザは試していません。

開始日に2026年11月5日を打ち、終了日のピッカーを開くと1日から4日が灰色になっている。終了日に11月8日を打つと「期間:4日間」になる。開始日の日を10に打ち直すと、1を押した時点で「期間:8日間」になり、0を押すと「終了日は開始日以降にしてください」と表示される
開始日を打つ、終了日のピッカーを開く、終了日を打つ、開始日を打ち直す、の4つの操作。期間の表示は、変わった瞬間だけピンクになる。4回目は、打っている途中の「1」の時点でも表示が変わる(文字のピンク、カーソル、クリックの輪、キー、操作の回数の表示は GIF 用に重ねたもの)

終了日の min に、開始日を渡す

DateRangeInput は、入力欄の見出し(label)と、フォームで送るときの2つの名前(startName、endName)を受け取ります。全体を fieldset で囲み、legend に見出しを書きます。

tsx
import { useState } from 'react';
import { daysBetween } from './days';

// 年を4桁までにする(付けないと、Chromium では年に6桁まで打てる)
const MAX_DATE = '9999-12-31';

type Props = {
  label: string;
  startName: string;
  endName: string;
  required?: boolean;
};

export function DateRangeInput({ label, startName, endName, required }: Props) {
  const [start, setStart] = useState('');
  const [end, setEnd] = useState('');

  let message = '期間:未選択';
  if (start && end) {
    message = start <= end ? `期間:${daysBetween(start, end) + 1}日間` : '終了日は開始日以降にしてください';
  }

  return (
    <fieldset className="date-range">
      <legend>{label}</legend>
      <label>
        開始日
        <input
          type="date"
          name={startName}
          value={start}
          max={MAX_DATE}
          required={required}
          onChange={(e) => setStart(e.target.value)}
        />
      </label>
      <label>
        終了日
        <input
          type="date"
          name={endName}
          value={end}
          min={start || undefined}
          max={MAX_DATE}
          required={required}
          onChange={(e) => setEnd(e.target.value)}
        />
      </label>
      <p className="days">{message}</p>
    </fieldset>
  );
}

value と onChange を渡しているので、2つの入力欄は React が値を管理する「制御されたコンポーネント」です。React の公式リファレンスにある input の説明のとおり、空の状態から始めるときは、state の初期値を undefined ではなく空文字列にします。

MDN の input type="date" の説明によると、画面に出る日付の形はブラウザの言語の設定で変わりますが、value はいつも yyyy-mm-dd の形の文字列です。検証環境でも、画面には「2026/11/05」と出て、value は "2026-11-05" でした。

min に渡すのは、開始日の state そのものです。開始日がまだ空のときは undefined を渡し、min を付けないようにしています。開始日に 2026年11月1日を入れると、終了日の min は "2026-11-01" になりました。

アクセシビリティツリーは、後で載せる App(label に「宿泊の期間」を渡した画面)で、Playwright を使って書き出しました。「宿泊の期間」というグループの中に、名前の付いた入力欄が2つ並びます。

text
- group "宿泊の期間":
  - text: 宿泊の期間 開始日
  - textbox "開始日"
  - text: 終了日
  - textbox "終了日"
  - paragraph: 期間:未選択

ピッカーでは選べないが、キーボードでは打てる

開始日を 2026年11月5日にしてから、終了日のカレンダーのアイコンをクリックすると、ピッカーは2026年11月を開き、1日から4日を灰色で表示しました(上の GIF の2回目の操作)。MDN の min と max の例にも、範囲の外の日付はピッカーで選べなくなると書かれています。

ただし、キーボードでは範囲の外の日付も打てました。開始日を 2026年11月5日にして、終了日に「20261103」と打つと、終了日の value は "2026-11-03" になります。このとき、入力欄は min を下回っているという状態(validity.rangeUnderflow が true)になり、送信ボタンを押しても送信の処理は呼ばれませんでした。

text
終了日の validity: {"valid":false,"rangeUnderflow":true,"validationMessage":"Value must be 2026/11/05 or later."}

validationMessage は、検証環境の Chromium では英語でした。MDN の入力の検証の節にあるとおり、範囲の外の日付を送信しようとすると、ブラウザがエラーを出します。つまり min は、キーボードでの入力は止めず、送信の時点で止める仕組みです。

キーボードでは開始日より前の終了日も入るので、画面の下の文もその場合に備えています。start <= end のときだけ日数を出し、そうでなければ「終了日は開始日以降にしてください」と出します。2つの値はどちらも yyyy-mm-dd の形なので、年が4桁であるかぎり、文字列のまま <= で比べれば日付の前後と一致します。検証環境でも、"2026-11-05" > "2026-11-03" は true、"2026-11-05" > "2026-12-01" は false でした。

開始日を後ろにずらしても、終了日は消さない

開始日を終了日より後ろにずらしたとき、onChange の中で終了日を空にしたくなるかもしれません。比べるために、開始日の onChange で次の処理をする版も作りました。

tsx
const handleStartChange = (value: string) => {
  setStart(value);
  if (value && end && value > end) setEnd('');
};

この版で、開始日 2026年11月1日、終了日 2027年1月10日の状態から、開始日を 2027年1月5日に打ち直しました。開始日の年の部分にフォーカスを置き、「20270105」と1文字ずつ打つと、onChange は打っている途中の値でも呼ばれます。

押したキー 開始日の value 終了日の value
2 0002-11-01 2027-01-10
0 0020-11-01 2027-01-10
2 0202-11-01 2027-01-10
7 2027-11-01 空
0 空 空
1 2027-01-01 空
0 空 空
5 2027-01-05 空

年を打ち終えた時点では、月はまだ11月のままです。そのため開始日は一瞬 2027-11-01 になり、終了日より後ろになって、終了日が消えました。打ち終えた開始日 2027年1月5日は終了日より前なのに、終了日は戻りません。

記事の版(終了日を消さない版)で同じ操作をすると、終了日は最後まで 2027-01-10 のままでした。

注意

onChange は、打っている途中の値でも呼ばれます。途中の値をもとに、もう一方の入力欄を消したり書き換えたりすると、利用者が入れた日付が失われます。開始日と終了日の前後が合わないときは、値は残したまま、メッセージと min による送信の停止で知らせるほうが安全です。

表で「空」になっているのは、月や日の部分が「00」のようなありえない値になっている間です。value に日付が入るのは、年・月・日がそろって正しい日付になったときだけでした。

max="9999-12-31" で、年を4桁までにする

max に "9999-12-31" を渡しているのは、年の打ち間違いを防ぐためです。検証環境の Chromium では、max を付けない入力欄に「2026110103」と続けて打つと、年の部分に6桁まで入り、value は "202611-01-03" になりました。max="9999-12-31" を付けた入力欄では、年を4桁打つと月へ移り、value は "2026-11-03" になりました。

text
max なし "202611-01-03"
max あり "2026-11-03"

年が5桁以上になると、文字列での前後の比べ方も成り立たなくなります。年を4桁打つと月へ移る動きは Chromium でしか確かめていません。ただ、MDN の max の説明によると、max より後の日付は入力の検証で不合格になるので、ほかのブラウザで5桁の年が入ったとしても、送信の前に止まるはずです。

送信される値と、必須にしたときの動き

フォームの中に置くと、2つの入力欄の value が、それぞれの name の名前で送られます。検証では、送信のときに FormData の中身を書き出す画面を用意しました。

tsx
import { useState, type FormEvent } from 'react';
import { DateRangeInput } from './DateRangeInput';

function App() {
  const [sent, setSent] = useState('');

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    setSent(JSON.stringify(Object.fromEntries(data)));
  };

  return (
    <form onSubmit={handleSubmit}>
      <DateRangeInput label="宿泊の期間" startName="checkin" endName="checkout" required />
      <button type="submit">送信</button>
      <p id="sent">{sent}</p>
    </form>
  );
}

開始日 2026年11月1日、終了日 2026年11月3日で送信すると、{"checkin":"2026-11-01","checkout":"2026-11-03"} が送られました。

required を渡すと、2つの入力欄の両方に付きます。何も入れずに送信ボタンを押すと、checkValidity() は false になり、送信の処理は呼ばれませんでした。フォーカスは開始日に移り、validationMessage は「Please fill out this field.」でした。

日数は Date.parse で数える

画面の「期間:3日間」は、daysBetween で求めた差に1を足した数です。開始日と終了日の両方を数えるので、11月1日から3日までは3日間になります。

ts
// "YYYY-MM-DD" の文字列2つから、日数の差を求める。
// 日付だけの文字列は UTC の0時として読まれるので、夏時間の影響を受けない。
export function daysBetween(start: string, end: string): number {
  return (Date.parse(end) - Date.parse(start)) / 86_400_000;
}

MDN の Date.parse() の説明によると、"2019-01-01" のような日付だけの文字列は、UTC の時刻として読まれます。検証環境でも、Date.parse("2026-11-01") は 2026-11-01T00:00:00.000Z を表す値になりました。UTC には夏時間がないので、2つの日付の差は、いつも 86,400,000 ミリ秒(1日)の倍数になります。

比べるために、年・月・日を new Date(年, 月 - 1, 日) に渡して、その環境の時刻で日数を求める書き方も試しました。Node.js でタイムゾーンを変えて実行した結果です。

タイムゾーン 期間 Date.parse で計算 その環境の時刻で計算 その環境の時刻で計算して Math.floor
Asia/Tokyo 2026-03-07 → 03-09 2 2 2
America/New_York 2026-03-07 → 03-09 2 1.958… 1
America/New_York 2026-10-31 → 11-02 2 2.041… 2

IANA のタイムゾーンデータベースの北米の定義では、America/New_York の夏時間は3月の第2日曜日に始まり、11月の第1日曜日に終わります。2026年では3月8日と11月1日で、表の2つの期間はこの日をまたぐため、1日が23時間や25時間になる日が入ります。その環境の時刻で計算すると割り切れなくなり、Math.floor で切り捨てると1日少なくなりました。同じデータベースのアジアの定義では、日本の夏時間は1951年が最後です。日本の時刻(Asia/Tokyo)で試しているかぎり、この違いには気づけません。Date.parse で日付だけの文字列を読む書き方なら、どちらのタイムゾーンでも2になりました。

​Posted by 86