Reactで、cronの式から次の実行時刻を一覧にする方法
cron の式から次の実行時刻を出すには、cron-parser の CronExpressionParser.parse() に、式と「いつから数えるか」と「どのタイムゾーンで数えるか」を渡します。返ってきたものから take(5) で5件取り出せば、次の5回分の時刻が Date で手に入ります。
ここでは、cron の式とタイムゾーンを選ぶと次の5回を表示する CronNextRuns という部品を作りました。計算は cronNext.ts にまとめています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、cron-parser 5.10.3、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。時計は Playwright の page.clock で 2026年11月5日 10:17(日本時間)などに合わせています。Firefox と Safari、スマートフォンでの表示と、cron の本体(cronie など)で実際にジョブを動かしたときの時刻は試していません。

cron-parser で、次の5回の時刻を取り出す
計算の関数です。
// cron の式から、from より後の実行時刻を count 件求める
import { CronExpressionParser } from 'cron-parser';
export type NextRuns =
| { ok: true; dates: Date[] }
| { ok: false; message: string };
export function nextRuns(expr: string, tz: string, from: Date, count = 5): NextRuns {
const text = expr.trim();
const fieldCount = text === '' ? 0 : text.split(/\s+/).length;
// cron-parser は足りない項目を * で埋め、6項目なら先頭を「秒」として読む。
// 書き間違いに気づけるように、@daily などの別名以外は5項目だけを受け付ける
if (!text.startsWith('@') && fieldCount !== 5) {
return {
ok: false,
message: `項目が${fieldCount}個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください`,
};
}
try {
const interval = CronExpressionParser.parse(text, { currentDate: from, tz });
return { ok: true, dates: interval.take(count).map((d) => d.toDate()) };
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) };
}
}
currentDate は数え始める時刻、tz は式を読むタイムゾーンです。cron-parser の README のオプションの表に、どちらも載っています。take() が返すのは cron-parser の CronDate なので、toDate() で Date に直しています。
返ってくる時刻は、currentDate より後のものだけです。Node.js で 0 9 * * * を日本時間で数えると、8時59分59秒999 から数えた場合は当日の9時が先頭でした。ちょうど9時0分0秒000 から数えた場合は、翌日の9時が先頭になりました。
項目の数は、部品の側で確かめる
cron-parser に渡す前に項目の数を数えているのは、5つでない式でもエラーにならないためです。検証環境で cron-parser にそのまま渡すと、空の文字列と * * * は、どちらも毎分の式として読まれました。0 9 * * * * のように6つ書くと、先頭が「秒」になり、「毎時9分」の時刻が並びました。秒から始まる6つの形は、README の「Cron Format」の図にも載っています。
入力欄に打っている途中の式も、そのまま計算に回ります。項目が足りないまま毎分の時刻が並ぶと、まだ書き終わっていないことに気づきにくいので、@daily のような別名を除いて5つのときだけ計算するようにしました。
値がおかしい式は、cron-parser がエラーを投げます。部品では、そのメッセージを「読み取れませんでした:」の後ろに出しています。
| 入力した式 | 表示されたメッセージ |
|---|---|
* * * |
項目が3個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください |
0 9 * * * * |
項目が6個です。「分 時 日 月 曜日」の5つを空白で区切って書いてください |
60 * * * * |
Constraint error, got value 60 expected range 0-59 |
0 9 * * 8 |
Constraint error, got value 8 expected range 0-7 |
0 9 31 2 * |
Invalid explicit day of month definition |
@reboot |
Validation error, cannot resolve alias "reb" |
2月31日のように、ありえない日付の式もエラーになりました。一方で 0 0 29 2 *(2月29日)は、2028年、2032年…と、うるう年の2月29日が5件並びました。
部品にまとめる
import { useEffect, useId, useMemo, useState } from 'react';
import { nextRuns } from './cronNext';
const LOCAL_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
const ZONES = [...new Set([LOCAL_ZONE, 'Asia/Tokyo', 'UTC', 'America/New_York'])];
// setTimeout に渡せる待ち時間は約24.8日まで。長い待ちは1時間ごとに区切る
const MAX_WAIT = 60 * 60 * 1000;
export function CronNextRuns({ defaultExpr = '0 9 * * 1-5' }: { defaultExpr?: string }) {
const id = useId();
const [expr, setExpr] = useState(defaultExpr);
const [tz, setTz] = useState(LOCAL_ZONE);
const [now, setNow] = useState(() => new Date());
const result = useMemo(() => nextRuns(expr, tz, now), [expr, tz, now]);
const format = useMemo(
() =>
new Intl.DateTimeFormat('ja-JP', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}),
[tz],
);
// いちばん近い実行時刻を過ぎたら、now を進めて一覧を出し直す
const first = result.ok ? result.dates[0]?.getTime() : undefined;
useEffect(() => {
if (first === undefined) return;
const wait = Math.min(Math.max(first - Date.now(), 0), MAX_WAIT);
const timer = setTimeout(() => setNow(new Date()), wait);
return () => clearTimeout(timer);
}, [first, now]);
return (
<div className="cron-next">
<label htmlFor={`${id}-expr`}>cron の式(分 時 日 月 曜日)</label>
<input
id={`${id}-expr`}
value={expr}
onChange={(e) => setExpr(e.target.value)}
spellCheck={false}
autoComplete="off"
/>
<label htmlFor={`${id}-tz`}>タイムゾーン</label>
<select id={`${id}-tz`} value={tz} onChange={(e) => setTz(e.target.value)}>
{ZONES.map((z) => (
<option key={z} value={z}>
{z === LOCAL_ZONE ? `${z}(このブラウザ)` : z}
</option>
))}
</select>
{result.ok ? (
<ol aria-label="次の実行時刻">
{result.dates.map((d) => (
<li key={d.getTime()}>
<time dateTime={d.toISOString()}>{format.format(d)}</time>
</li>
))}
</ol>
) : (
<p role="alert">読み取れませんでした:{result.message}</p>
)}
</div>
);
}
.cron-next {
display: grid;
gap: 6px;
max-width: 360px;
}
.cron-next input {
font: 1.1rem ui-monospace, monospace;
padding: 6px 8px;
}
.cron-next ol {
margin: 8px 0 0;
padding-left: 1.6em;
font-variant-numeric: tabular-nums;
}
.cron-next [role='alert'] {
color: #d85878;
}
計算は、式・タイムゾーン・現在時刻のどれかが変わったときだけやり直せば足りるので、useMemo に入れています。表示に使う Intl.DateTimeFormat にも、timeZone オプションで同じ tz を渡しています。cron の式を読むタイムゾーンと、画面に出すタイムゾーンをそろえるためです。
タイムゾーンの選択肢の先頭は、ブラウザのタイムゾーンです。Playwright でブラウザのタイムゾーンを Asia/Tokyo にして開くと「Asia/Tokyo(このブラウザ)」が、America/New_York にして開くと「America/New_York(このブラウザ)」が選ばれた状態で表示されました。
タイムゾーンを変えると、同じ式でも時刻が変わる
cron の式には、タイムゾーンが書かれていません。同じ 0 9 * * 1-5 でも、どの地域の9時かで実際の時刻が変わります。日本時間の 2026年11月5日 10:17 に開いた部品でタイムゾーンを切り替えると、次のように並びました。
| タイムゾーン | 先頭の時刻(表示) | 先頭の時刻(UTC) |
|---|---|---|
| Asia/Tokyo | 2026/11/06(金) 09:00 | 2026-11-06T00:00Z |
| UTC | 2026/11/05(木) 09:00 | 2026-11-05T09:00Z |
| America/New_York | 2026/11/05(木) 09:00 | 2026-11-05T14:00Z |
日本時間の5日9時はもう過ぎているので、Asia/Tokyo では翌日6日(金)が先頭です。UTC ではまだ5日の1時17分なので、5日(木)の9時が先頭になりました。ニューヨークはまだ4日(水)の20時17分で、次の平日の9時は5日(木)です。
cron の本体がどのタイムゾーンで式を読むかは、実装と設定で決まります。たとえば cronie の crontab(5) では、CRON_TZ という変数で crontab ごとのタイムゾーンを指定でき、その地域の時刻で式を書くように説明されています。部品で選ぶタイムゾーンは、ジョブを動かす側の設定に合わせます。
日と曜日を両方書くと、どちらかに当てはまる日に動く
cron の式では、「日」と「曜日」の両方に * 以外を書くと、両方に当てはまる日ではなく、どちらかに当てはまる日になります。cronie の crontab(5) にもそう書かれていて、例に挙がっている 30 4 1,15 * 5 は「毎月1日と15日、それに毎週金曜の4時30分」です。
部品にこの式を入れると、11月6日(金)、13日(金)、15日(日)、20日(金)、27日(金)の4時30分が並びました。15日は日曜ですが、日の欄に当てはまるので入っています。0 9 1 * 1 も「毎月1日の月曜」ではなく、11月9日から30日までの月曜4回と12月1日(火)が並びました。
cron-parser の README の Strict Mode の説明では、いくつかの cron の実装では日と曜日のどちらを優先するかがあいまいだとして、strict: true にすると両方の指定をエラーにできると書かれています。ただし strict モードは6項目(秒から書く形)も必須にするため、5項目で書く部品では使っていません。
「毎月第2月曜」のように日と曜日を組み合わせた指定は、cron-parser では曜日の欄に 1#2 と書けます。0 9 * * 1#2 は、11月9日、12月14日、1月11日…と、各月の第2月曜が並びました。月末の L(0 0 L * *)も、11月30日、12月31日、1月31日、2月28日のように各月の最後の日になりました。どちらも README の「Special Characters」の表にある書き方です。cronie の crontab(5) には、L や # の書き方は載っていません。ジョブを動かす側で同じ式が使えるかは、その実装の説明で確かめてください。
実行時刻を過ぎたら、一覧を進める
部品を開いたままにしていると、先頭の時刻はいつか過去になります。そこで useEffect で、先頭の時刻までの待ち時間を setTimeout に渡し、時間が来たら now を新しくして計算し直しています。cron-parser は currentDate より後の時刻だけを返すので、ちょうど先頭の時刻に計算し直せば、その時刻は一覧から外れます。
*/15 * * * * で確かめると、10時17分には先頭が「10:30」でした。時計を10時29分59秒まで進めても「10:30」のままで、10時30分0秒になったところで先頭が「10:45」に変わりました。
待ち時間を MAX_WAIT(1時間)で頭打ちにしているのは、setTimeout に渡せる待ち時間に上限があるためです。MDN の setTimeout() の説明によると、上限は 2,147,483,647 ミリ秒(約24.8日)で、超えるとオーバーフローします。検証環境の Chromium で30日分の待ち時間を渡すと、0ミリ秒で呼ばれました。次の実行が約24.8日より先になる式では、待ち時間をそのまま渡しても、指定したとおりには待ちません。
1時間で区切った部品では、@yearly の先頭は「2027/01/01(金) 00:00」で、1時間進めても変わりませんでした。さらに57日進めて2027年1月1日の11時30分にすると、先頭は「2028/01/01(土) 00:00」に変わっていました。依存配列に now も入れているので、1時間ごとにタイマーを掛け直し、そのたびに待ち時間を計り直します。
夏時間の切り替わる日も、cron-parser は1日1回として数える
夏時間のあるタイムゾーンでは、1日の中に「ない時刻」と「2回ある時刻」ができます。America/New_York のブラウザで、時計を夏時間の始まる前日(2026年3月7日)にして 30 2 * * * を入れると、次のように並びました。
2026/03/07(土) 02:30
2026/03/08(日) 03:30
2026/03/09(月) 02:30
2026/03/10(火) 02:30
2026/03/11(水) 02:30
3月8日は2時台がない日で、cron-parser は3時30分として数えました。夏時間の終わる11月1日は1時台が2回ありますが、30 1 * * * では、11月1日の1時30分は1回だけでした(UTC で 05:30、夏時間のほうの1時30分です)。
cron-parser を入れると、ブラウザに送る量が増える
cron-parser は、タイムゾーンの計算に Luxon を使っています(README の「Timezone Support」の節)。cronNext.ts だけを esbuild で本番用に縮めてまとめると、100,508 バイト(gzip で 30,859 バイト)になりました。そのうち Luxon が 71,234 バイト、cron-parser が 28,128 バイトです。





