Frontend System Design: Date Range Picker (Airbnb / Booking.com)

interviewOctober 12, 2026ยท 7 min read

"Design a date range picker" sounds like the easiest question in the system design interview, which is exactly why senior loops love it. The junior answer is "a calendar with two input fields." The senior answer knows that Airbnb's picker took a design team years to get right, that a "day" is not always 24 hours long, and that the hard part was never rendering a grid โ€” it's the selection state machine and the constraint system hiding behind two innocent clicks.

A date range picker is three systems stacked: a rendering problem (generate two months of day cells, cheap), a state problem (every click and hover means something different depending on where you are in the selection), and a constraint problem (disabled dates, minimum stays, and invalid ranges that must make the interaction itself communicate what's possible). Let's design all three.

Requirements โ€” the questions that split junior from senior

Before drawing anything, ask:

  • Single date or range? A trip planner needs check-in + check-out. A report filter needs start + end inclusive. A flight search needs one-way, return, and flexible dates. The state machine changes with each.
  • What constraints? Are any dates unselectable (sold-out nights, past dates, blocked dates from a holiday rental's iCal sync)? Minimum or maximum stay lengths? Maximum advance booking window (Airbnb caps at ~2 years)?
  • How many months visible? One, two (Booking.com default), or a scrolling infinite calendar (Airbnb)? Mobile one-month vs desktop two-month is a responsive design question.
  • Timezones? Is "Oct 5" the date in the user's timezone, the hotel's, or neither? This answer determines your entire data model.
  • Editable inputs? Can users type dates directly into an input, and does typed input sync back into the calendar?

I'll assume the full production version โ€” a travel-booking picker with constraints, two visible months, keyboard support, and URL-synced state โ€” because that's where the interview points end up anyway.

The naive answer โ€” and exactly where it dies

// The answer everyone gives first
function DateRangePicker() {
  const [startDate, setStartDate] = useState(null);
  const [endDate, setEndDate] = useState(null);

  return (
    <div>
      <input type="date" onChange={(e) => setStartDate(new Date(e.target.value))} />
      <input type="date" onChange={(e) => setEndDate(new Date(e.target.value))} />
      <CalendarGrid
        onSelect={(day) => !startDate ? setStartDate(day) : setEndDate(day)}
      />
    </div>
  );
}

This passes a phone screen and dies on contact with reality:

  1. The selection is not two clicks. Click a start, then click a day before the start โ€” now your range is undefined. Click a third time with both set โ€” nothing happens. Every sequence of clicks needs defined meaning, and "if/else in the handler" is where sequences like click-cancel-restart go to die.
  2. new Date() everywhere. new Date("2026-10-12") parses as UTC midnight; new Date(2026, 9, 12) is local midnight. Mix them and "Oct 12" displays as "Oct 11" for half the world's users. Worse: subtract two dates across a DST boundary and you get 23 hours, not 24.
  3. No constraint awareness. Nothing stops a range spanning a sold-out night, violating a two-night minimum, or ending before it starts.
  4. Zero accessibility. A pile of <div>s with click handlers is invisible to a keyboard user โ€” and a booking flow a keyboard user can't complete is a booking you lost.

Each failure has a nameable fix. That's the rest of this design.

Rendering โ€” a pure month-matrix function

The grid is the easy part, so make it boring: a pure function that turns (year, month) into a fixed 6ร—7 matrix of day cells, memoized per month.

function getMonthMatrix(year, month, weekStartsOn = 1) {
  // Always return 6 weeks โ€” stable row count prevents layout shift
  const first = new Date(Date.UTC(year, month, 1));
  const lead = (first.getUTCDay() - weekStartsOn + 7) % 7;
  const cells = [];
  for (let i = 0; i < 42; i++) {
    const date = new Date(Date.UTC(year, month, 1 - lead + i));
    cells.push({
      iso: date.toISOString().slice(0, 10), // "2026-10-12"
      inMonth: date.getUTCMonth() === month,
    });
  }
  return cells;
}

Three deliberate choices in those ten lines:

  • Date.UTC only. All calendar math happens in UTC where every day is exactly 86,400 seconds. This is the foundation the DST section below depends on.
  • ISO strings as cell identity. "2026-10-12" compares with ===, sorts lexicographically, serializes to URLs, and never drifts by an hour.
  • Fixed 42 cells. Months that fit in 5 weeks get a sixth empty row; the calendar's height never changes and focus traversal never falls off a different-shaped grid.

Memoize the matrix per (year, month, weekStartsOn) โ€” it's pure, so WeakMap or module-level cache works. Two visible months means at most two matrix computations for the entire session until the user navigates.

Virtualization, which candidates reach for reflexively, is a trap here: you render 42 cells per month, maybe 84 total. That's nothing. The real rendering risk is hover, which we'll handle in the performance section.

The data model โ€” calendar dates, not timestamps

Here's the single most important decision, and the one interviewers wait to hear: the domain model is a calendar date, and a calendar date is not an instant in time.

// โœ… The model: plain ISO calendar dates
{ checkIn: "2026-10-12", checkOut: "2026-10-15" }

// โŒ The model that breaks: instants
{ checkIn: 1791772800000, checkOut: 1792032000000 }

"Check-in October 12th" is a date on a calendar โ€” it's the same date whether you ask in London or Los Angeles. Storing it as a timestamp bakes in a timezone you never meant, and every conversion becomes a chance to show the wrong day.

The DST proof, the one to deliver in interviews:

// Europe/London, October 2026: clocks go back on the 25th
const a = new Date(2026, 9, 25); // local midnight
const b = new Date(2026, 9, 26); // local midnight
(b - a) / 86_400_000; // 1 โ€” wait, that one's fine...
// But on March 29 2026 (spring forward):
const c = new Date(2026, 2, 28);
const d = new Date(2026, 2, 29);
(d - c) / 86_400_000; // 1... but only because *midnights* behave.
// Subtract *timestamps from the API* instead and you get 23 or 25 hours.

Any code that computes "number of nights" by dividing a timestamp difference by 86,400,000 will produce 0-night or 2-night stays somewhere in the world, on some day, every year. The fix is structural: normalize every date to UTC midnight at the boundary, do all math on those, convert to local only for display. If your nightsBetween() function is Math.round((b - a) / 86_400_000), say out loud that the Math.round is a bug confession.

The selection state machine โ€” the actual senior answer

Two clicks have five distinct behaviors depending on selection state. That's a state machine, so model it as one โ€” this is the same insight behind the spreadsheet's editing state machine and the star rating's click/hover model.

function selectionReducer(state, action) {
  switch (action.type) {
    case "DAY_CLICK": {
      const { iso } = action;
      if (state.phase === "EMPTY" || state.phase === "COMPLETE")
        return { phase: "START_SELECTED", start: iso, end: null };
      if (state.phase === "START_SELECTED") {
        if (iso < state.start)  // clicked before start โ†’ restart, not swap
          return { phase: "START_SELECTED", start: iso, end: null };
        if (iso === state.start) return state; // same day โ†’ no-op (or 0-night per domain)
        return constraints.isValid(state.start, iso)
          ? { phase: "COMPLETE", start: state.start, end: iso }
          : { phase: "START_SELECTED", start: iso, end: null }; // invalid โ†’ new start
      }
      return state;
    }
    case "DAY_HOVER":
      return state.phase === "START_SELECTED"
        ? { ...state, hover: action.iso }
        : state;
    case "ESCAPE":
      return { phase: "EMPTY", start: null, end: null };
  }
}

The decisions hiding in that reducer are what the interview is actually testing:

  • Click before the start = new start, not a swap. Airbnb restarts the selection; silently swapping start/end surprises users mid-flow. Say you made this a product decision, not an oversight.
  • Clicking an invalid end day starts a new selection from that day. This is Airbnb's actual behavior โ€” the interaction itself teaches users what's possible. A toast saying "invalid range" is a worse answer.
  • Same-day click is a domain decision. Hotel booking: a no-op or a one-night stay. Meeting rooms: meaningful. Name the question you'd ask the PM.
  • Hover only matters mid-selection. The preview range โ€” "what will I get if I click here" โ€” is derived state (hover between start and itself), never stored as truth.
  • Escape and outside-click cancel. A picker that can't be dismissed is a modal in disguise.

Derived preview for rendering:

function getPreview(state) {
  if (state.phase !== "START_SELECTED" || !state.hover) return null;
  const [from, to] = state.hover < state.start
    ? [state.hover, state.start]
    : [state.start, state.hover];
  return constraints.isValid(from, to) ? { from, to } : null; // null โ†’ render as invalid
}

Constraints โ€” where Airbnb hides its engineering

Constraints are a predicate layer over the calendar, evaluated per cell and per candidate range:

const constraints = {
  isSelectable(iso) {
    return iso >= todayISO() && !soldOutNights.has(iso) && iso <= maxAdvanceISO();
  },
  isValid(start, end) {
    const nights = nightsBetween(start, end); // UTC day-number math
    if (nights < minNights(start)) return false;   // min stay varies per date!
    if (nights > 30) return false;                  // Airbnb's 30-night cap
    return !rangeTouchesBlocked(start, end);        // no sold-out night inside
  },
};

The senior-level wrinkles:

  • Min-stay is a property of the check-in date, not the range. "Friday and Saturday require 2 nights" means a range starting Thursday needs to know Friday is inside it. minNights(start) reads a per-date table, and a candidate range is valid only if the minimum is satisfied for its actual start day.
  • Blocked nights inside a range invalidate the whole range, and the picker must show it during hover: gray out the preview, or (Airbnb's approach) make the click start a new selection from the blocked day. This is why validation runs on hover-preview, not just on commit.
  • Past dates and booking windows are just more entries in isSelectable โ€” the point in interviews is showing they're one predicate layer, not scattered conditionals across the render code.

Keyboard & accessibility โ€” the ARIA grid

A date picker is the textbook ARIA grid pattern, and saying "I'd follow the WAI-ARIA grid pattern" out loud is half the credit:

  • Structure: role="grid" on the month, role="row" per week, role="gridcell" per day with aria-selected, aria-disabled, and aria-current="date" for today.
  • One tab stop per calendar. Arrow keys move a roving focus across days; Tab exits the widget entirely. A tab stop on every one of 42 cells is an a11y failure interviewers watch for.
  • Arrows move ยฑ1 day, PageUp/PageDown move ยฑ1 month, Home/End jump to week boundaries โ€” and focus crosses month boundaries, dragging the visible window with it (the spreadsheet grid's keyboard traversal, transplanted to dates).
  • Enter/Space commits the click; Escape cancels the selection โ€” both mirroring the mouse reducer, because it's the same reducer. State machines make mouse and keyboard free.
  • An aria-live region announces selection progress: "October 12 selected. Choose a check-out date." Screen-reader users are otherwise choosing dates blind.
  • The trigger input itself: aria-expanded on the button, focus returned to it on close.

Performance โ€” hover without re-rendering the world

The one real perf risk: during mid-selection, every mousemove over a day cell updates the preview range โ€” and a naive implementation re-renders all 84 visible cells on every crossing, times sixty events a second.

The fixes, in the order to present them:

  1. Memoized day cells. Each cell takes (iso, isStart, isEnd, isInRange, isDisabled); memoized cells re-render only when their own flags flip. Moving the mouse one day over flips ~2 cells' props, not 84's.
  2. mouseover, not mousemove, on the grid with event delegation โ€” one listener on the month container, fired per cell entry. 60 events/second becomes ~5.
  3. requestAnimationFrame-batch the hover dispatch so rapid crossings coalesce โ€” the identical trick from the kanban board's drag targeting.
  4. Range highlighting as a single derived preview value flowing down, never per-cell computation against startDate/endDate props.

Even without memoization, 84 cells is small โ€” so say the honest thing: "this grid is small enough that plain React handles it; I'm doing the memoization because hover dispatches are high-frequency, not because the render count is large." Interviewers trust calibration more than reflexive optimization.

i18n, persistence, and the edges

  • Week start differs by locale (Sunday US, Monday UK/EU) โ€” already a parameter in the matrix function. Date display formats come from Intl.DateTimeFormat, never hand-rolled.
  • URL sync, Booking.com-style: ?checkin=2026-10-12&checkout=2026-10-15. ISO strings serialize losslessly, share links keep working months later, and the picker hydrates from the URL on load. This is the same URL-as-source-of-truth move as the spreadsheet's persistence, and saying the keyword "deep-linkable search" earns the point.
  • Typed input โ†” calendar sync: parsing typed dates is locale-dependent (10/12 means October in the US, December in the UK) โ€” use Intl.DateTimeFormat formatToParts and validate against the same constraint layer. One validation path, two input modes.
  • Timezone normalization at the API boundary: the hotel's server thinks in its timezone; send "2026-10-12", never a timestamp, and let the backend own the property-local semantics.

Interview gotchas โ€” quick-fire

  • Using new Date() / timestamp math for "nights between" โ†’ DST makes days 23 or 25 hours; do math on UTC-midnight ISO dates only.
  • Two-click selection as if/else in a click handler โ†’ it's a state machine; draw the states before writing code.
  • Clicking before the start silently swapping endpoints โ†’ restart the selection; it's a product decision, name it.
  • Validating the range only on commit โ†’ validate on hover; the preview is the error message.
  • Min-stay applied as one number โ†’ it's per-check-in-date ("weekends need 2 nights").
  • 42 tab stops โ†’ one tab stop, roving focus, ARIA grid pattern.
  • mousemove โ†’ mouseover + delegation + rAF batching.
  • No Escape/outside-click/URL sync โ†’ the picker must integrate with the page, not just exist in it.

Putting it together

A production date range picker design:

  • Rendering: pure, memoized 6ร—7 month matrices on Date.UTC; fixed cell count; two visible months; no virtualization needed and saying why is the point
  • Data model: ISO calendar-date strings as the single source of truth; timestamps only at display and API boundaries โ€” DST-proof by construction
  • Selection: an explicit state machine (EMPTY โ†’ START_SELECTED โ†’ COMPLETE) where click-before-start restarts and invalid targets start anew โ€” interaction as constraint communication
  • Constraints: one predicate layer (isSelectable, isValid) covering past dates, sold-out nights, per-date minimum stays, and booking windows, evaluated during hover, not just commit
  • A11y: ARIA grid, roving focus, live-region announcements; keyboard and mouse drive the same reducer
  • Performance: delegated mouseover + rAF batching + memoized cells for high-frequency hover dispatches
  • Integration: URL-synced ISO dates, locale-aware typed input, Intl formatting

The grid is the easy 10%. The state machine and the constraint layer are the product โ€” and the answer.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby