Reactで、開始日と終了日を選ぶ入力欄を作る方法
開始日と終了日を選ぶ入力欄は、<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、スマートフォンのブラウザは試していません。

終了日の min に、開始日を渡す
DateRangeInput は、入力欄の見出し(label)と、フォームで送るときの2つの名前(startName、endName)を受け取ります。全体を fieldset で囲み、legend に見出しを書きます。
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つ並びます。
- 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)になり、送信ボタンを押しても送信の処理は呼ばれませんでした。
終了日の 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 で次の処理をする版も作りました。
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" になりました。
max なし "202611-01-03"
max あり "2026-11-03"
年が5桁以上になると、文字列での前後の比べ方も成り立たなくなります。年を4桁打つと月へ移る動きは Chromium でしか確かめていません。ただ、MDN の max の説明によると、max より後の日付は入力の検証で不合格になるので、ほかのブラウザで5桁の年が入ったとしても、送信の前に止まるはずです。
送信される値と、必須にしたときの動き
フォームの中に置くと、2つの入力欄の value が、それぞれの name の名前で送られます。検証では、送信のときに FormData の中身を書き出す画面を用意しました。
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日間になります。
// "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になりました。





