Skip to Content
Piplup

Date pickers with React Hook Form

Use MUI X date pickers in react-hook-form: set up the picker, load dates from an API, submit them in the right format, validate a date range and use Zod.

React Hook Form has no date input of its own, and a date picker is a controlled component, so it needs the same wiring as any MUI input plus a few date-specific decisions: what type to store, how to load and submit dates, and how to validate ranges. This guide covers each with the MUI X pickers and @piplup/rhf-adapters, using Dayjs.

1. Set up the picker

Install React Hook Form, MUI X and a date library:

npm install react-hook-form @piplup/rhf-core @piplup/rhf-adapters @mui/material @emotion/react @emotion/styled @mui/x-date-pickers dayjs

Every MUI X picker needs a LocalizationProvider above it, with the adapter for your date library. Put it at the root of your app, or around the form:

src/appointment-form.tsx
import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { MuiXDatePickerElement } from "@piplup/rhf-adapters/mui-x-date-pickers";
import type { Dayjs } from "dayjs";
import { useForm } from "react-hook-form";

type Appointment = { date: Dayjs | null };

export function AppointmentForm() {
  const { control, handleSubmit } = useForm<Appointment>({
    defaultValues: { date: null },
  });

  return (
    <LocalizationProvider dateAdapter={AdapterDayjs}>
      <form noValidate onSubmit={handleSubmit((values) => console.log(values))}>
        <MuiXDatePickerElement control={control} name="date" label="Date" required />
      </form>
    </LocalizationProvider>
  );
}

MuiXDatePickerElement replaces a Controller with a render function. It stores the picked date, shows validation errors as the picker's helper text, and moves focus to the picker when it fails validation. noValidate stops the browser's own popup for required, so React Hook Form's message shows.

Times work the same way with MuiXTimePickerElement and MuiXDateTimePickerElement.

2. Store date objects, convert at the edges

The field holds whatever your adapter uses: a Dayjs object with AdapterDayjs, a Date with AdapterDateFns. An empty picker is null, so start with null in defaultValues rather than "" or undefined.

Keep date objects in the form and convert only when data comes in or goes out. The picker can then compare dates for minDate and similar limits without parsing strings.

Load dates from an API

APIs send dates as strings. Parse them while building defaultValues:

import dayjs from "dayjs";

type ApiBooking = { checkIn: string | null; checkOut: string | null };

function toFormValues(booking: ApiBooking) {
  return {
    checkIn: booking.checkIn ? dayjs(booking.checkIn) : null,
    checkOut: booking.checkOut ? dayjs(booking.checkOut) : null,
  };
}

const { control } = useForm({ defaultValues: toFormValues(booking) });

If the data arrives after the form mounts, call reset(toFormValues(booking)) when it loads, or pass the result as useForm's values option so the form follows the data.

Submit in the format your API expects

Format the dates in the submit handler:

const onSubmit = handleSubmit((values) =>
  api.saveBooking({
    checkIn: values.checkIn?.format("YYYY-MM-DD") ?? null,
    checkOut: values.checkOut?.format("YYYY-MM-DD") ?? null,
  }),
);

Choose the format by what the value means:

The value isSendExample
A calendar date, such as a birthday or a check-in dayformat("YYYY-MM-DD")2026-11-20
An exact moment, such as a meeting starttoISOString()2026-11-20T09:30:00.000Z

A date picker returns local midnight of the picked day. toISOString() converts that to UTC, which is the previous day for users east of UTC, so a calendar date sent that way can arrive a day off. Pickers also take a timezone prop, which the element uses for validation as well.

3. Require a date and set limits

required makes the picker mandatory. The picker's own limits, such as minDate, maxDate, disablePast, disableFuture and shouldDisableDate, grey out days in the calendar and also validate the form, so a date typed by hand outside the range fails on submit with its own message:

<MuiXDatePickerElement
  control={control}
  name="birthday"
  label="Date of birth"
  required
  disableFuture
  messages={{
    required: "Enter your date of birth",
    disableFuture: "Date of birth can't be in the future",
    invalidDate: "Enter a date such as 05/31/1990",
  }}
/>

A date typed in a format the picker can't read fails as invalidDate. The element page lists every limit and its default message.

4. Validate a date range

For a start and end date, pass the start date as the end picker's minDate. Pick both dates and book, then move the check-in past the check-out:

MM/DD/YYYY
MM/DD/YYYY
"use client";import Stack from "@mui/material/Stack";import { LocalizationProvider } from "@mui/x-date-pickers";import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";import { MuiButtonElement } from "@piplup/rhf-adapters/mui-material";import { MuiXDatePickerElement } from "@piplup/rhf-adapters/mui-x-date-pickers";import type { Dayjs } from "dayjs";import { useForm, useWatch } from "react-hook-form";type Booking = { checkIn: Dayjs | null; checkOut: Dayjs | null };export default function Page() {  const { control, handleSubmit } = useForm<Booking>({    defaultValues: { checkIn: null, checkOut: null },  });  const checkIn = useWatch({ control, name: "checkIn" });  return (    <LocalizationProvider dateAdapter={AdapterDayjs}>      <form        noValidate        onSubmit={handleSubmit((values) =>          alert(            JSON.stringify(              {                checkIn: values.checkIn?.format("YYYY-MM-DD"),                checkOut: values.checkOut?.format("YYYY-MM-DD"),              },              null,              2,            ),          ),        )}      >        <Stack direction="column" spacing={2}>          <MuiXDatePickerElement            control={control}            name="checkIn"            label="Check-in"            required            disablePast            rules={{ deps: "checkOut" }}            messages={{              required: "Pick a check-in date",              disablePast: "Check-in can't be in the past",            }}          />          <MuiXDatePickerElement            control={control}            name="checkOut"            label="Check-out"            required            minDate={checkIn ? checkIn.add(1, "day") : undefined}            messages={{              required: "Pick a check-out date",              minDate: "Check-out must be after check-in",            }}          />          <div>            <MuiButtonElement control={control} variant="contained" type="submit">              Book            </MuiButtonElement>          </div>        </Stack>      </form>    </LocalizationProvider>  );}

Two details make the range behave:

  • useWatch for the start date. It re-renders the form when check-in changes, so the check-out picker's minDate follows it. checkIn.add(1, "day") requires at least one night; pass checkIn itself to allow the same day.
  • rules={{ deps: "checkOut" }} on the start picker. After the first submit, React Hook Form revalidates a field when it changes, but only that field. deps revalidates check-out whenever check-in changes too, so moving check-in past check-out shows the error straight away instead of on the next submit.

MUI X also has a DateRangePicker, but it's part of the paid MUI X Pro. Two pickers like these work with the free package.

5. Validate with Zod

With a schema, the date rules live next to the rest of your validation. z.custom checks for a valid Dayjs object, and a .refine on the object compares the two dates:

src/booking-schema.ts
import dayjs, { type Dayjs } from "dayjs";
import { z } from "zod";

const date = (message: string) =>
  z
    .custom<Dayjs | null>((value) => dayjs.isDayjs(value) && value.isValid(), message)
    .transform((value) => value as Dayjs);

export const bookingSchema = z
  .object({
    checkIn: date("Pick a check-in date").refine(
      (value) => !value.isBefore(dayjs(), "day"),
      "Check-in can't be in the past",
    ),
    checkOut: date("Pick a check-out date"),
  })
  .refine((values) => values.checkOut.isAfter(values.checkIn, "day"), {
    path: ["checkOut"],
    message: "Check-out must be after check-in",
  });

The transform makes the parsed type Dayjs while the form's type still allows null. Pass both types to useForm, and the submit handler gets dates that are never null:

import { zodResolver } from "@hookform/resolvers/zod";

const { control, handleSubmit } = useForm<
  z.input<typeof bookingSchema>,
  unknown,
  z.output<typeof bookingSchema>
>({
  resolver: zodResolver(bookingSchema),
  defaultValues: { checkIn: null, checkOut: null },
});

With a resolver, React Hook Form skips field-level rules. minDate, disablePast and the other limits still grey out days in the calendar, but they no longer validate, and neither does required. Put every rule in the schema, as above.

Keep rules={{ deps: "checkOut" }} on the check-in picker, so the range error updates when check-in changes. The refine sets path: ["checkOut"], which puts its message under the check-out picker.

6. Other date inputs

Every MUI X component that holds a value has an element in @piplup/rhf-adapters/mui-x-date-pickers, except DateTimeField:

Each has a matching hook, such as useMuiXDatePickerAdapter, for a custom picker component.

FAQ

How do I use a date picker with React Hook Form?

Date pickers are controlled components, so connect them with a Controller or an element that wraps one. With MUI X, wrap the form in LocalizationProvider with a date adapter such as AdapterDayjs, and render MuiXDatePickerElement from @piplup/rhf-adapters/mui-x-date-pickers with control and name.

Should I store dates as strings or date objects in the form?

Store the date object your picker adapter uses, such as a Dayjs object, and use null for an empty picker. Convert strings from your API into date objects when you build defaultValues, and format dates back into strings when you submit. The picker can then compare and validate dates without parsing.

Why is my date one day off after I submit?

toISOString() converts the picked local midnight to UTC, which falls on the previous day for users east of UTC, such as in Europe or Asia. For a date without a time, send format("YYYY-MM-DD") instead. Use toISOString() for a date and time that marks an exact moment.

How do I make the end date come after the start date?

Watch the start date with useWatch and pass it, plus one day, as the end picker's minDate. Add rules={{ deps: "checkOut" }} to the start picker so that, once the form has been submitted, the end date revalidates when the start date changes.

How do I validate dates with Zod?

Use z.custom with dayjs.isDayjs(value) && value.isValid() for each date, then a .refine on the object for rules across dates, with path set to the field that should show the error. With a resolver, react-hook-form skips field-level rules, so the picker's minDate and disablePast no longer validate; put those limits in the schema.

See also

On this page