Skip to content

Recurring events

Give an event a recurrence and the scheduler draws one event for each occurrence in the visible range. You keep one event in your data, not hundreds.

const standup = {
id: "standup",
title: "Standup",
startDate: new Date(2026, 9, 5, 9), // the first occurrence: Monday 9:00
endDate: new Date(2026, 9, 5, 9, 30),
resourceId: "ann",
recurrence: {
freq: "weekly", // "hourly" | "daily" | "weekly" | "monthly" | "yearly"
byWeekday: [1, 2, 3, 4, 5], // weekly, monthly, yearly; 0 = Sunday. Weekly default: the weekday of startDate
interval: 1, // every n hours, days, weeks, months or years
// monthly and yearly also take byMonthDay: [1, -1] and bySetPos: [-1], see "Rarer rules"
until: new Date(2026, 11, 18), // last day it may start on, inclusive
count: 40, // or: how many in total, counting the first
exceptions: [new Date(2026, 9, 12)], // days to skip
},
};
  • The event’s own startDate and endDate are the first occurrence. Its time of day and length repeat, including multi-day events.
  • until, count and exceptions are all optional. With neither until nor count it repeats for ever.
  • Monthly and yearly keep the day of the month. A month without that day is skipped, as in iCalendar (31 January repeats in March, not on 28 February). The same goes for 29 February.
  • Times follow the wall clock, so 9:00 stays 9:00 across a daylight saving change. With the timeZone prop that is the wall clock of that zone, whatever zone the viewer’s machine is in (see Time zones).
  • Weeks start on Monday for interval and byWeekday, as in iCalendar.
  • exceptions are matched by calendar day and do not change count. For hourly they are matched by the exact start of the occurrence.
// The last Friday of every month (-1 counts from the end; 1 is the first, 2 the second...)
{ freq: "monthly", byWeekday: [5], bySetPos: [-1] }
// The second Tuesday, every 2 months
{ freq: "monthly", interval: 2, byWeekday: [2], bySetPos: [2] }
// The last weekday of the month (a payroll run)
{ freq: "monthly", byWeekday: [1, 2, 3, 4, 5], bySetPos: [-1] }
// The 1st and the last day of the month (-1 is the 28th, 29th, 30th or 31st)
{ freq: "monthly", byMonthDay: [1, -1] }
// Friday the 13th: byWeekday and byMonthDay must both match
{ freq: "monthly", byWeekday: [5], byMonthDay: [13] }
// Every 4 hours, for a long job or a rolling watch
{ freq: "hourly", interval: 4, count: 12 }
  • byMonthDay (1 to 31, or negative from the end of the month) and byWeekday narrow the days of each month; bySetPos then picks from what is left ([-1] the last, [1, -1] the first and the last). A month that has no such day (the 31st, a fifth Friday) is skipped, and count only counts occurrences that exist.
  • Yearly does the same in the month the series starts in: { freq: "yearly", byWeekday: [5], bySetPos: [-1] } from 5 October is the last Friday of every October.
  • The series starts at the first matching day on or after startDate. If startDate itself does not match (a Monday start with “last Friday”), it is not an occurrence, as for weekly byWeekday.
  • byMonthDay and bySetPos are ignored by hourly, daily and weekly; byWeekday is ignored by hourly and daily.
  • Hourly steps the clock, like every other frequency: every 4 hours from 08:00 is 08:00, 12:00, 16:00 … on every day, also on a day the clocks change (so it is not always 4 real hours apart, and a day with 25 hours has the same occurrences as any other). A reading the clock skips, 02:30 when the clocks go forward, is not an occurrence and is not counted, as in iCalendar. The ids carry the time, "<id>::2026-10-05T09:00". until is the last moment it may start at, exceptions and the date you pass to splitRecurrence and editOccurrence are the exact start of an occurrence, and a split ends the first series 1 ms before the split point.

Recurring events work wherever events do: in resource.events, or in the flat events prop.

The scheduler hands your handlers an occurrence: a copy of the event with id "<id>::<yyyy-MM-dd>", its own startDate and endDate, seriesId set to the original id, and no recurrence. Your data is never changed for you.

<ResourceScheduler
resources={people}
events={events}
onEventDrop={(occurrence, fromId, toId, start, end) => {
if (!occurrence.seriesId) return setEvents(moveOneOff(occurrence, toId, start, end));
// It is part of a series. Decide, or ask the user, what to change.
}}
/>

The two usual choices, both shown in the demo above:

  • This event only. Add the occurrence’s day to the series’ exceptions, and add a normal one-off event at the new time.
  • The whole series. Change the series itself: its resourceId, or the time of day of startDate and endDate.

Two helpers do the bookkeeping for you. Both are pure: they return new events and change nothing.

import { editOccurrence, splitRecurrence } from "resource-scheduler";
// "This and following": the old series ends the day before, a new one starts at the occurrence.
const { before, after } = splitRecurrence(standup, occurrenceStart, {
title: "Planning",
startDate: newStart,
endDate: newEnd,
});
// before: standup with recurrence.until = the day before (its count is dropped)
// after: a new series, id "<id>::from-<yyyy-MM-dd>", with the edits applied
// "This occurrence only": an exception on the series plus a one-off event.
const { series, changed } = editOccurrence(standup, occurrenceStart, { startDate: newStart, endDate: newEnd });
  • occurrence is any time on that day, and it must be an occurrence of the event; otherwise both functions throw a RangeError.
  • after keeps a count as what is left (count minus the occurrences before the split, exceptions included), keeps until, and carries over the exceptions from the split on. It keeps the time of day and length unless the edits give new dates. Put recurrence in the edits to change the rule, or id to choose the id.
  • before is null when the occurrence is the first one: nothing is left of the old series, so remove it.
  • changed gets the id <id>::edit-<yyyy-MM-dd> and seriesId, and no recurrence unless the edits set one.

Conflict rules (eventOverlap, isValidDrop, blockUnavailable) see the occurrences, so a new booking cannot land on top of one.

The same expansion is exported, for a server, an export or your own list:

import { expandRecurrence, expandEvents } from "resource-scheduler";
expandRecurrence(standup, from, to); // the occurrences touching from..to (to exclusive)
expandEvents(events, from, to); // expands the recurring ones in a list, passes the rest through

parseRRule and toRRule turn an iCalendar RRULE into this rule and back, for storage or an API that speaks iCalendar: parseRRule("FREQ=MONTHLY;BYDAY=-1FR") is { freq: "monthly", byWeekday: [5], bySetPos: [-1] }. icsToEvents and eventsToICS use them.

  • Rules beyond the fields above: BYMONTH other than the month the series starts in, BYYEARDAY, BYWEEKNO, WKST other than Monday, minutely and secondly frequencies. parseRRule throws a RangeError that names the part.
  • Time zones in the rule. A series repeats at the same wall-clock time; with the timeZone prop that is the time in the zone (see Time zones).