Reactで、土日と祝日を除いた営業日を数える方法
土日と祝日を除いた営業日は、祝日の一覧を "YYYY-MM-DD" をキーにしたオブジェクトで持ち、1日ずつ「土日でも祝日でもないか」を確かめて数えられます。日付を Date のまま比べず、input type="date" の値と同じ文字列で照らし合わせるのがコツです。こうすると、ブラウザのタイムゾーンが日本でなくても結果が変わりません。
ここでは、開始日から終了日までの営業日の数と、開始日から「何営業日後」の日付を出す BusinessDayCounter というコンポーネントを作りました。祝日の一覧には、npm で配布されている @holiday-jp/holiday_jp を使っています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、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、@holiday-jp/holiday_jp 2.5.2 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。タイムゾーンは Asia/Tokyo と America/New_York の2つで試しています。祝日の一覧を内閣府の CSV と照らし合わせる作業、Firefox と Safari、スマートフォンでの日付の入力、スクリーンリーダーでの読み上げは試していません。

祝日の一覧を用意する
祝日には、年ごとに日付が変わるものや、振替休日、その年だけの休日があります。@holiday-jp/holiday_jp の一覧では、海の日は2019年が7月15日、2020年が7月23日、2021年が7月22日です。2019年10月22日は「休日(祝日扱い)」でした。規則から計算するより、出来上がった一覧を使うほうが確実です。
@holiday-jp/holiday_jp は、holidays というオブジェクトに、"2026-05-06" のような日付をキーにして、祝日の名前などを入れています。検証環境の 2.5.2 には、1970年から2050年までの 1,329 件が入っていました。2026年の分には、振替休日の「こどもの日 振替休日」(5月6日)と、敬老の日と秋分の日に挟まれた「休日」(9月22日)も入っています。元になっている holiday_jp のデータセットの README によると、このデータは内閣府の祝日の CSV(syukujitsu.csv)と照らし合わせてテストされています。
祝日の一覧を読み込むファイルです。一覧に入っている年の範囲も、ここで出しておきます。
import holiday_jp from '@holiday-jp/holiday_jp';
import type { Holidays } from './businessDays';
export const holidays: Holidays = holiday_jp.holidays;
// 一覧に入っている最初の年の1月1日から、最後の年の12月31日まで
const dates = Object.keys(holidays).sort();
export const coverage = {
from: `${dates[0].slice(0, 4)}-01-01`,
to: `${dates[dates.length - 1].slice(0, 4)}-12-31`,
};
import holiday_jp from の形で読み込むため、tsconfig.json に "esModuleInterop": true を入れて型チェックしました。
営業日を数える関数
計算はこのファイルにまとめました。
// input type="date" の value("YYYY-MM-DD")で、土日と祝日を除いた営業日を扱う関数
// 祝日の一覧。キーは "YYYY-MM-DD"
export type Holidays = Readonly<Record<string, { readonly name: string }>>;
const MS_PER_DAY = 86_400_000;
// 1970-01-01 から何日目か(UTC で数えるので、タイムゾーンの影響を受けない)
function toDayNumber(value: string): number {
const [y, m, d] = value.split('-').map(Number);
const date = new Date(0);
date.setUTCFullYear(y, m - 1, d);
return date.getTime() / MS_PER_DAY;
}
function fromDayNumber(n: number): string {
return new Date(n * MS_PER_DAY).toISOString().slice(0, 10);
}
// 土日でも祝日でもない日か
export function isBusinessDay(value: string, holidays: Holidays): boolean {
const dow = new Date(toDayNumber(value) * MS_PER_DAY).getUTCDay(); // 0 = 日曜 … 6 = 土曜
return dow !== 0 && dow !== 6 && !Object.hasOwn(holidays, value);
}
// start から end まで(両端を含む)の営業日の数と、平日に当たったので除いた祝日
export function countBusinessDays(start: string, end: string, holidays: Holidays) {
let count = 0;
const skipped: { date: string; name: string }[] = [];
for (let n = toDayNumber(start); n <= toDayNumber(end); n++) {
const value = fromDayNumber(n);
if (isBusinessDay(value, holidays)) count++;
else if (Object.hasOwn(holidays, value) && isBusinessDay(value, {})) {
skipped.push({ date: value, name: holidays[value].name });
}
}
return { count, skipped };
}
// start の翌日から数えて、days 日目の営業日
export function addBusinessDays(start: string, days: number, holidays: Holidays): string {
let n = toDayNumber(start);
for (let left = days; left > 0; ) {
n++;
if (isBusinessDay(fromDayNumber(n), holidays)) left--;
}
return fromDayNumber(n);
}
日付は「1970年1月1日から何日目か」という整数にして、1日ずつ進めています。曜日を求めるのも、文字列に戻すのも UTC で行うので、ブラウザのタイムゾーンによって日付がずれません。toISOString() は、MDN の Date の説明にあるとおり、いつも UTC の日時を返します。その先頭の10文字が、そのまま "YYYY-MM-DD" になります。
祝日かどうかは Object.hasOwn() で確かめています。MDN の説明のとおり、Object.hasOwn() はオブジェクト自身が持つプロパティだけを見て、toString のように継承したプロパティには false を返します。
数え方は、開始日と終了日の両方を含めています。検証環境で、Asia/Tokyo と America/New_York のどちらでも次の結果になりました。
| 期間 | 営業日 | 除いた平日の祝日 |
|---|---|---|
| 2026-04-27〜2026-05-08 | 6 | 4/29、5/4、5/5、5/6 |
| 2026-09-18〜2026-09-25 | 3 | 9/21、9/22、9/23 |
| 2026-05-02〜2026-05-06 | 0 | 5/4、5/5、5/6 |
| 2026-01-01〜2026-12-31 | 244 | 17日 |
2026年5月3日の憲法記念日は日曜なので、skipped には入りません。その代わりに5月6日が振替休日として一覧に入っているので、平日の祝日として除かれます。
addBusinessDays は、開始日を数えずに、翌日から営業日を数えます。2026年4月28日の3営業日後は、4月30日、5月1日を経て、連休明けの5月7日でした。5月1日(金曜)の1営業日後も、5月2日(土曜)の1営業日後も、5月7日になります。
この2つの関数を、別の求め方と比べました。ローカル時刻の Date を1日ずつ進め、holiday_jp.isHoliday() で祝日を判定して数える方法です。1970年から2050年の中からランダムに選んだ2,000組の期間で、営業日の数はすべて一致しました。addBusinessDays の結果も、2,000組すべてで「営業日であり、開始日の翌日からその日までの営業日がちょうど指定した日数」になっていました。1970年1月1日から2050年12月31日まで(営業日は 20,083 日)を1日ずつ数えても、Node.js で 50ミリ秒かかりませんでした。
isHoliday() に Date を渡すと、タイムゾーンによって別の日として判定される
@holiday-jp/holiday_jp には、祝日かどうかを返す isHoliday() もあります。パッケージのコードを読むと、Date を渡したときは getFullYear()、getMonth()、getDate() でローカルの年月日に直してから一覧を引いていました。そのため、Date の作り方によっては、ブラウザのタイムゾーンで結果が変わります。
new Date('2026-01-02') のような日付だけの文字列は、MDN の Date の説明にあるとおり、UTC の0時として読まれます。検証環境で試すと、結果は次のとおりでした。
| タイムゾーン | new Date('2026-01-02') のローカルの日付 |
isHoliday(new Date('2026-01-02')) |
|---|---|---|
| Asia/Tokyo | 2026-01-02 | false |
| America/New_York | 2026-01-01 | true |
America/New_York では、1月2日のつもりの Date が1月1日として扱われ、元日と判定されました。isHoliday('2026-01-02') のように文字列で渡すと、どちらのタイムゾーンでも false でした。この記事の関数が、ローカルの年月日に直さず、"YYYY-MM-DD" の文字列で holidays のキーを直接引いているのは、このずれを避けるためです。
コンポーネントにまとめる
import { useId, useState } from 'react';
import { addBusinessDays, countBusinessDays, type Holidays } from './businessDays';
type Props = {
holidays: Holidays;
// 祝日の一覧が用意されている範囲("YYYY-MM-DD")
coverage: { from: string; to: string };
defaultStart?: string;
defaultEnd?: string;
};
export function BusinessDayCounter({ holidays, coverage, defaultStart = '', defaultEnd = '' }: Props) {
const id = useId();
const [start, setStart] = useState(defaultStart);
const [end, setEnd] = useState(defaultEnd);
const [days, setDays] = useState(10);
// "YYYY-MM-DD" は、文字列のまま比べても日付の順になる
const inCoverage = (v: string) => coverage.from <= v && v <= coverage.to;
const ready = start !== '' && end !== '' && start <= end;
const result = ready && inCoverage(start) && inCoverage(end)
? countBusinessDays(start, end, holidays)
: null;
const validDays = Number.isInteger(days) && days >= 1 && days <= 1000;
const due = start !== '' && inCoverage(start) && validDays
? addBusinessDays(start, days, holidays)
: null;
return (
<div className="business-days">
<label htmlFor={`${id}-start`}>開始日</label>
<input id={`${id}-start`} type="date" min={coverage.from} max={coverage.to}
value={start} onChange={(e) => setStart(e.target.value)} />
<label htmlFor={`${id}-end`}>終了日</label>
<input id={`${id}-end`} type="date" min={coverage.from} max={coverage.to}
value={end} onChange={(e) => setEnd(e.target.value)} />
<output htmlFor={`${id}-start ${id}-end`} aria-live="polite">
{!ready ? (
<p>開始日と、それより後の終了日を選んでください</p>
) : !result ? (
<p>{coverage.from.slice(0, 4)}年から{coverage.to.slice(0, 4)}年までの日付を選んでください</p>
) : (
<>
<p className="count">営業日 <strong>{result.count}</strong> 日</p>
{result.skipped.length > 0 && (
<>
<p>平日の祝日(数えていません)</p>
<ul>
{result.skipped.map((h) => (
<li key={h.date}>{h.date.slice(5).replace('-', '/')} {h.name}</li>
))}
</ul>
</>
)}
</>
)}
</output>
<label htmlFor={`${id}-days`}>営業日数</label>
<input id={`${id}-days`} type="number" min={1} max={1000}
value={Number.isNaN(days) ? '' : days} onChange={(e) => setDays(e.target.valueAsNumber)} />
<output htmlFor={`${id}-start ${id}-days`} className="due">
{!due
? '範囲内の開始日と、1〜1000 の営業日数を入れてください'
: inCoverage(due)
? `開始日の ${days} 営業日後は ${due}`
: '祝日の一覧がない年にかかるため、計算できません'}
</output>
</div>
);
}
.business-days {
display: grid;
grid-template-columns: max-content 1fr;
gap: 8px 12px;
align-items: center;
max-width: 360px;
}
.business-days output {
grid-column: 1 / -1;
}
.business-days output p,
.business-days ul {
margin: 4px 0;
}
.business-days .count strong {
font-size: 2rem;
color: #067a98;
}
使う側では、祝日の一覧と範囲を渡します。
<BusinessDayCounter holidays={holidays} coverage={coverage}
defaultStart="2026-04-27" defaultEnd="2026-05-08" />
日付の欄の値は、MDN の input type="date" の説明のとおり、表示の書式にかかわらず yyyy-mm-dd の形です。年を4桁で書く範囲では、文字列のまま大小を比べれば日付の前後が分かるので、start <= end や inCoverage は文字列の比較で書いています。
Playwright で操作した結果は、次のとおりでした。Asia/Tokyo と America/New_York で、表示はすべて同じです。
| 操作 | 営業日の表示 | 何営業日後の表示 |
|---|---|---|
| 開始日 2026-09-18、終了日 2026-09-25、営業日数 3 | 営業日 3 日(敬老の日、休日、秋分の日) | 2026-09-28 |
| 開始日 2026-12-25、終了日 2027-01-08、営業日数 10 | 営業日 10 日(元日) | 2027-01-12 |
| 終了日を開始日より前にする | 開始日と、それより後の終了日を選んでください | 2027-01-12 |
| 営業日数を空にする | (上と同じ) | 範囲内の開始日と、1〜1000 の営業日数を入れてください |
営業日数を 0 にしたときも、同じ案内が出ました。
祝日の一覧がない年は計算しない
一覧にない年では、countBusinessDays は土日だけを除いて数えます。一覧に2051年の祝日は1件もなく、2051年1月1日から10日までを数えると、土日でない日と同じ7日になりました。そこでコンポーネントでは、一覧の範囲の外では計算しないようにしました。
日付の欄には min と max で範囲を入れていますが、MDN の input type="date" の max 属性の説明のとおり、範囲の外の値は「制約検証に失敗する」だけで、入力そのものは止まりません。検証環境でも、max="2050-12-31" の欄にキーボードで「01012051」と打つと、value は "2051-01-01" になりました。そこで、inCoverage で範囲を確かめています。開始日 2050-12-20、終了日 2051-01-10 では「1970年から2050年までの日付を選んでください」が出ました。2050年12月20日の10営業日後は2051年になるので、「祝日の一覧がない年にかかるため、計算できません」と出ます。
会社の休みを足す
年末年始の休みのように、祝日ではない休みを除きたいときは、一覧にキーを足したオブジェクトを渡します。
const companyHolidays: Holidays = {
...holidays,
'2026-12-29': { name: '年末年始休業' },
'2026-12-30': { name: '年末年始休業' },
'2026-12-31': { name: '年末年始休業' },
'2027-01-02': { name: '年末年始休業' },
'2027-01-03': { name: '年末年始休業' },
};
2026年12月21日から2027年1月8日までの営業日は、祝日だけを除くと14日、休業日も除くと11日になりました。2026年12月25日の10営業日後は、2027年1月12日から1月15日に変わりました。スプレッド構文で新しいオブジェクトを作っているので、元の holidays には12月29日が入らないままでした。
使う年の分だけ読み込むと、ファイルが小さくなる
holiday_jp.holidays を読み込むと、1970年から2050年までの一覧がまるごとビルドに入ります。本番用に縮小してビルドすると、ファイルは 401,609 バイトで、そのうち祝日の一覧まわり(@holiday-jp/holiday_jp と、それを読み込むファイル)が 175,164 バイトでした。
holiday_jp-js の README には、年ごとのファイルを読み込んで setHolidays() に渡す書き方が載っています。年ごとのファイルの中身は、その年の祝日だけを入れた、holidays と同じ形のオブジェクトでした。この記事の関数には同じ形のオブジェクトを渡せばよいので、年ごとのファイルをスプレッド構文でつなげて渡しました。
import h2026 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2026';
import h2027 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2027';
import h2028 from '@holiday-jp/holiday_jp/lib/holidays_every_year/2028';
import type { Holidays } from './businessDays';
// 使う年の分だけ読み込む
export const holidays: Holidays = { ...h2026, ...h2027, ...h2028 };
export const coverage = { from: '2026-01-01', to: '2028-12-31' };
この形に変えると、ファイルは 233,294 バイト、祝日の一覧まわりは 6,849 バイトになりました。gzip で圧縮した大きさでは、82,656 バイトから 71,347 バイトに減っています。coverage も3年分に変わるので、日付の欄の min と max も 2026-01-01 と 2028-12-31 になりました。





