The four options
| Library | UI | Expo Go | Best for | Trade-off |
|---|---|---|---|---|
| @react-native-community/datetimepicker | Real native controls | ✅ Yes | Almost every app | Different API per platform |
| react-native-date-picker | Its own modal wheel | ❌ Dev build | Identical look on both platforms | Extra native dependency |
| react-native-calendars | A month grid you style | ✅ Yes | Ranges, availability, marked days | You own a11y and localisation |
| Three text inputs | Whatever you build | ✅ Yes | Birthdays far in the past | Validation and locale order (DD/MM vs MM/DD) |
Start with the community picker unless you have a specific reason not to. It gives you the control the user already knows how to operate, in their locale, with their date format and their accessibility settings, for free. “It does not match our design system” is a real reason to switch — but it costs you all four of those, so make the trade deliberately rather than because a wheel picker looked nicer in Figma.
One wrapper, both platforms
npx expo install @react-native-community/datetimepickerThe trap is that the same JSX means two different things. On Android, mounting the component opens a system dialog and the component unmounts itself when it closes. On iOS it renders an inline view that sits in your layout until you remove it. A wrapper that hides the difference:
import { useState } from 'react';
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
export function DateField({ label, value, onChange, minimumDate, maximumDate }) {
const [open, setOpen] = useState(false);
function handleChange(event, selected) {
// Android: the dialog is already closing — always drop our own flag
if (Platform.OS === 'android') setOpen(false);
if (event.type === 'dismissed') return; // user hit Cancel
if (selected) onChange(selected);
}
return (
<View style={styles.field}>
<Text style={styles.label}>{label}</Text>
<Pressable style={styles.trigger} onPress={() => setOpen(true)}>
<Text style={styles.value}>
{value ? value.toLocaleDateString() : 'Select a date'}
</Text>
</Pressable>
{open && (
<DateTimePicker
value={value ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'default'}
minimumDate={minimumDate}
maximumDate={maximumDate}
onChange={handleChange}
/>
)}
{/* iOS keeps the picker mounted, so give it an explicit way out */}
{open && Platform.OS === 'ios' && (
<Pressable onPress={() => setOpen(false)}>
<Text style={styles.done}>Done</Text>
</Pressable>
)}
</View>
);
}toLocaleDateString() in the trigger is deliberate. A hardcoded MM/DD/YYYYis read as day-first by most of the world, so “03/04” means two different days depending on who is holding the phone. Let the platform format it.
For a time, pass mode="time". Android has no combined mode, so a date-and-time value means opening the date picker and then the time picker in sequence from its onChange — which is exactly why keeping this in one wrapper component is worth it.
The bug that reaches production: one day off
This one survives review because it is invisible to anyone developing in UTC or ahead of it. The picker gives you local midnight. toISOString() converts to UTC. In any timezone behind UTC, that lands on the previous day:
// user in Los Angeles picks 4 March
const picked = new Date(2026, 2, 4); // 2026-03-04T00:00 local
picked.toISOString(); // "2026-03-03T08:00:00.000Z" ← 3rd
picked.toISOString().split('T')[0]; // "2026-03-03" ← saved wrong
// store a calendar day as a calendar day
function toDateKey(d) {
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
toDateKey(picked); // "2026-03-04" ← correctThe rule underneath it: decide whether the field is a calendar day or a moment in time, and store it accordingly. A birthday, a due date, or a habit check-in is a calendar day — store "2026-03-04"and never attach a timezone to it. An appointment at 14:00, a message timestamp, or a reminder is a moment — store a full ISO string in UTC and render it in the viewer’s local time.
Mixing the two is what produces streaks that break at 11pm, reminders that fire an hour early after the clocks change, and bookings that land on the wrong day for exactly the users who are furthest from your office.
Ranges, and constraining at the picker
The community picker has no range mode. Two fields, with the second constrained by the first, covers most of what people actually need:
const [start, setStart] = useState(null);
const [end, setEnd] = useState(null);
<DateField label="Check in" value={start} onChange={setStart} minimumDate={new Date()} />
<DateField label="Check out" value={end} onChange={setEnd} minimumDate={start ?? new Date()} />Constraining with minimumDate beats validating on submit, because the user never gets to make the mistake. The same applies to a date of birth — maximumDate set to today removes an entire error message from your form. See the form validation guide for where the remaining checks belong.
When the range is the interface — availability calendars, booking grids, anything with marked and blocked days — switch to react-native-calendars and its period marking. That is a different component with a different job, not a bigger date picker.
Skip the boilerplate
Every app with a booking flow, a reminder, or a tracker writes this same wrapper — the platform branch, the dismissed event, the date-key formatter. It is not hard, it is just the kind of thing that is wrong in production for six months before someone in a negative UTC offset files a bug.
Describe the screen instead — “a booking form with a service picker, a date and time field, and a confirmation screen” — and ShipNative generates it as real React Native with the picker wiring in place, running on your phone in a couple of minutes. Export the project and the wrapper is yours to change.