React Date Range Picker: Patterns, Libraries, and Production-Ready Examples

A practical guide to building robust React date range pickers with UX tips, code examples, validation, time zones, and testing strategies.

ASOasis
8 min read
React Date Range Picker: Patterns, Libraries, and Production-Ready Examples

Image used for representation purposes only.

Overview

Date range pickers are a staple of booking flows, analytics dashboards, and reporting tools. In React, you can implement range selection with off‑the‑shelf components or compose your own UX on top of headless calendars. This guide covers UX principles, popular libraries, production caveats (validation, time zones, accessibility), and complete code examples you can drop into an app today.

UX principles for range selection

Well‑designed range pickers do a few things consistently:

  • Start and end are visually distinct, with an inclusive highlight between them.
  • Hovering a second date previews the tentative range.
  • Disabled dates (e.g., before today) are visibly unavailable and non‑interactive.
  • Keyboard navigation works with arrow keys and Enter/Space to select.
  • Mobile layouts favor a single calendar view; desktop commonly shows two months side by side.
  • For analytics, the end date is often inclusive. For bookings/nights, the end is exclusive (checkout day).

Quick wins with a plug‑and‑play component (react-datepicker)

If you want minimal setup and a familiar UI, react-datepicker supports range selection out of the box.

import React from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';

export default function RangeWithReactDatePicker() {
  const [range, setRange] = React.useState<[Date | null, Date | null]>([null, null]);
  const [startDate, endDate] = range;

  return (
    <div>
      <label htmlFor="date-range" style={{ display: 'block', marginBottom: 8 }}>Date range</label>
      <DatePicker
        id="date-range"
        selectsRange
        startDate={startDate}
        endDate={endDate}
        onChange={(update: [Date | null, Date | null]) => setRange(update)}
        isClearable
        monthsShown={2}
        minDate={new Date()}
        placeholderText="Select a start and end date"
      />
      {startDate && endDate && (
        <p style={{ marginTop: 8 }}>
          Selected: {startDate.toDateString()}  {endDate.toDateString()}
        </p>
      )}
    </div>
  );
}

Notes:

  • Use minDate/maxDate to constrain ranges.
  • monthsShown={2} provides the dual‑calendar desktop pattern.
  • The onChange callback returns a tuple [start, end]. You can show a live “N nights” counter by computing the difference once both are set.

Quick presets (Last 7 days, This month)

You can add preset buttons that programmatically set the controlled value:

function addDays(base: Date, days: number) {
  const d = new Date(base);
  d.setDate(d.getDate() + days);
  return d;
}

function startOfMonth(d = new Date()) {
  return new Date(d.getFullYear(), d.getMonth(), 1);
}

function endOfMonth(d = new Date()) {
  return new Date(d.getFullYear(), d.getMonth() + 1, 0);
}

export function RangeWithPresets() {
  const [range, setRange] = React.useState<[Date | null, Date | null]>([null, null]);
  const [start, end] = range;

  return (
    <div>
      <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
        <button onClick={() => setRange([addDays(new Date(), -6), new Date()])}>Last 7 days</button>
        <button onClick={() => setRange([startOfMonth(), endOfMonth()])}>This month</button>
        <button onClick={() => setRange([null, null])}>Clear</button>
      </div>
      <DatePicker
        selectsRange
        startDate={start}
        endDate={end}
        onChange={(update: [Date | null, Date | null]) => setRange(update)}
        monthsShown={2}
      />
    </div>
  );
}

Headless/custom approach (react-day-picker)

For maximum control over rendering and behavior, react-day-picker is a great choice. It provides accessible, keyboard‑friendly calendars you can style yourself.

import * as React from 'react';
import { DayPicker, DateRange } from 'react-day-picker';
import 'react-day-picker/dist/style.css';

export default function RangeWithDayPicker() {
  const [range, setRange] = React.useState<DateRange | undefined>();

  return (
    <div>
      <DayPicker
        mode="range"
        selected={range}
        onSelect={setRange}
        numberOfMonths={2}
        disabled={{ before: new Date() }}
        pagedNavigation
      />
      <div style={{ marginTop: 8 }}>
        {range?.from && !range.to && <em>Choose an end date</em>}
        {range?.from && range?.to && (
          <span>
            {range.from.toDateString()}  {range.to.toDateString()}
          </span>
        )}
      </div>
    </div>
  );
}

Why headless?

  • You control the DOM and styles, making it easier to meet brand, a11y, and internationalization requirements.
  • You can implement advanced behaviors like multi‑range selection, blackout windows, or custom tooltips for price calendars.

Full‑featured visual picker (react-date-range)

react-date-range provides a polished dual‑calendar with built‑in range previews and well‑documented props.

import * as React from 'react';
import { DateRangePicker } from 'react-date-range';
import 'react-date-range/dist/styles.css';
import 'react-date-range/dist/theme/default.css';

export default function RangeWithReactDateRange() {
  const [selection, setSelection] = React.useState([
    { startDate: new Date(), endDate: null as Date | null, key: 'selection' },
  ]);

  return (
    <DateRangePicker
      ranges={selection}
      onChange={(item: any) => setSelection([item.selection])}
      minDate={new Date()}
      moveRangeOnFirstSelection={false}
      months={2}
      direction="horizontal"
    />
  );
}

Validation and constraints

Regardless of the library, keep these rules close to your state management:

type Range = { start?: Date | null; end?: Date | null };

export function clampRange(r: Range, {
  min = new Date(2000, 0, 1),
  max = new Date(2100, 11, 31),
}: { min?: Date; max?: Date } = {}): Range {
  const start = r.start && r.start < min ? min : r.start ?? null;
  const end = r.end && r.end > max ? max : r.end ?? null;
  // Ensure start <= end when both exist
  if (start && end && start > end) return { start: end, end: start };
  return { start, end };
}

Common constraints:

  • Disallow past dates for bookings (minDate = today).
  • Blackout maintenance windows or holidays.
  • Enforce max span (e.g., 31 days) for analytics queries.
export function isSpanWithin(days: number, start?: Date | null, end?: Date | null) {
  if (!start || !end) return true;
  const ms = Math.abs(end.getTime() - start.getTime());
  return ms <= days * 24 * 60 * 60 * 1000;
}

Time zones and serialization (avoid off‑by‑one errors)

Dates without times can drift when converted to UTC. Practical guidelines:

  • Decide whether your domain treats ranges in local time (most UIs) or UTC (server jobs). Be explicit.
  • Store date‑only values as ISO strings without a time component (YYYY‑MM‑DD) to avoid implicit zone shifts.
  • Convert at the edges: parse/format at boundaries between UI and API.
// Serialize to a date-only string in the user’s local time
export function toLocalDateString(d: Date) {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

// Deserialize from YYYY-MM-DD into a local Date at midnight local time
export function fromLocalDateString(s: string) {
  const [y, m, d] = s.split('-').map(Number);
  return new Date(y, m - 1, d);
}

Inclusive vs exclusive end:

  • Analytics: inclusive end. Duration = end − start + 1 day.
  • Stays/nights: exclusive end (checkout). Nights = end − start.
export function nightsBetween(checkIn: Date, checkOut: Date) {
  const msPerDay = 24 * 60 * 60 * 1000;
  const start = new Date(checkIn.getFullYear(), checkIn.getMonth(), checkIn.getDate());
  const end = new Date(checkOut.getFullYear(), checkOut.getMonth(), checkOut.getDate());
  return Math.round((end.getTime() - start.getTime()) / msPerDay);
}

Accessibility and internationalization

  • Always label inputs (aria-label or a visible
  • Ensure contrast for selected/hovered states and size targets for touch.
  • Test keyboard navigation end‑to‑end: Tab into input, arrows to move, Enter/Space to select.
  • Localize month/day names and date formats. Most libraries accept a locale prop or adapter; ensure you also localize helper text and presets.

Example (react-datepicker) with locale:

import { registerLocale } from 'react-datepicker';
import { fr } from 'date-fns/locale';
registerLocale('fr', fr);

<DatePicker locale="fr" /* ...other props */ />

Working with React Hook Form

Form libraries keep range state predictable and validate at submit time.

import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';

type FormValues = {
  range: [Date | null, Date | null];
};

export default function RHFRangeExample() {
  const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
    defaultValues: { range: [null, null] },
  });

  const onSubmit = (data: FormValues) => {
    const [start, end] = data.range;
    if (start && end) {
      console.log('Submitting', toLocalDateString(start), toLocalDateString(end));
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="range"
        control={control}
        rules={{
          validate: ([s, e]) => (s && e) || 'Please select a start and end date',
        }}
        render={({ field }) => (
          <DatePicker
            selectsRange
            startDate={field.value?.[0]}
            endDate={field.value?.[1]}
            onChange={field.onChange}
            monthsShown={2}
            placeholderText="Select range"
          />
        )}
      />
      {errors.range && <p role="alert">{errors.range.message}</p>}
      <button type="submit" style={{ marginTop: 8 }}>Apply</button>
    </form>
  );
}

Testing range pickers

UI libraries often render days as buttons or grid cells. Use accessible roles and labels for resilient tests.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RangeWithReactDatePicker from './RangeWithReactDatePicker';

test('selects a range', async () => {
  render(<RangeWithReactDatePicker />);
  const user = userEvent.setup();

  // Open the calendar if your picker uses an input/button trigger
  const input = screen.getByLabelText(/date range/i);
  await user.click(input);

  // Click two distinct days; exact queries depend on the library’s markup
  const day10 = screen.getByRole('button', { name: /10/ });
  const day15 = screen.getByRole('button', { name: /15/ });
  await user.click(day10);
  await user.click(day15);

  // Assert that helper text or input value reflects the selection
  expect(screen.getByText(/selected:/i)).toBeInTheDocument();
});

Tip: Prefer role‑based queries (button, gridcell) and accessible names (aria‑label) over brittle selectors.

Performance tips

  • Render two months by default; avoid rendering entire years unless virtualized.
  • Memoize heavy day content (e.g., price tooltips) and fetch lazily on month change.
  • Defer parsing/formatting to when values change, not on every render.
  • In SSR apps (Next.js), ensure the picker renders only on the client if it relies on window or layout effect.

Common pitfalls checklist

  • Off‑by‑one day when sending to the API: serialize as YYYY‑MM‑DD, not full ISO with time.
  • End before start: clamp or swap automatically for better UX.
  • Inclusive vs exclusive end disagreements between UI and backend: document and test.
  • Inconsistent locale: ensure calendar, input mask, and printed values share the same locale.
  • Accessibility gaps: verify keyboard navigation and screen reader labels.

Choosing the right approach

  • Need speed and familiarity: react-datepicker (range mode) is a great default.
  • Need fine‑grained control and accessible primitives: react-day-picker.
  • Need a polished dual‑calendar with built‑in previews and presets: react-date-range.

No matter which you choose, keep the domain rules (constraints, time zones, inclusive/exclusive semantics) close to your state, and test the full flow from selection to API payload. That’s what turns a nice calendar into a production‑ready range picker.

Related Posts