Pick the shape first
| Library | Shape | Native code | Use it for |
|---|---|---|---|
| react-native-calendars | Month grid, multi-month list, agenda | None — pure JS | Marked days, streaks, date ranges, bookings |
| flash-calendar | Month grid on FlashList | None — pure JS | Long scrollable ranges where the grid feels heavy |
| Calendar-kit style timeline | Day / week timeline with events | Gesture handler + Reanimated | Scheduling, shifts, room booking |
| Native date picker | System wheel or dialog | Native module | Pick one date — no calendar needed |
The question that decides it: does anything in your app happen at a time, or only on a day? A habit tracker, a period tracker, a streak, a booking availability grid — those are day-level, and a month grid is the whole feature. A shift roster or a therapy schedule is time-level, and a month grid will never be enough no matter how much you extend it. Deciding this before you install saves you the migration.
The bug: your dates are one day off
Every calendar library keys on 'YYYY-MM-DD' strings. The obvious way to produce one is the wrong way:
// WRONG — toISOString() converts to UTC first.
// 8 Sept, 9pm in New York -> '2026-09-09'
const key = new Date().toISOString().slice(0, 10);
// RIGHT — read the local calendar fields.
export function toDayKey(d: Date): string {
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}`;
}This is invisible in development if you build in Europe and your test data is generated at midday. It shows up as one-star reviews from users in the Americas saying the app marks yesterday, or that their streak resets at 7pm. The same class of bug bites the other way for users ahead of UTC.
The matching trap is stepping through a range by adding 86400000 milliseconds. On the two days a year the clocks change, that lands you on 23:00 the previous day or 01:00 the next, and your range silently skips or repeats a square. Step with setDate(d.getDate() + 1), which is calendar-aware:
export function daysBetween(start: Date, end: Date): string[] {
const out: string[] = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
while (cursor <= last) {
out.push(toDayKey(cursor));
cursor.setDate(cursor.getDate() + 1); // DST-safe
}
return out;
}A month grid that marks your data
The common screen — a month, dots on days that have entries, tap a day to filter the list below — is about forty lines. The part that matters is that markedDates is derived, memoized, and merged with the selection rather than rebuilt inline:
import { useMemo, useState } from 'react';
import { View } from 'react-native';
import { Calendar, LocaleConfig } from 'react-native-calendars';
LocaleConfig.defaultLocale = 'en'; // set once, at module scope
type Entry = { id: string; at: Date };
export function EntryCalendar({ entries }: { entries: Entry[] }) {
const [selected, setSelected] = useState(() => toDayKey(new Date()));
// Derived, not rebuilt in JSX — markedDates is compared by reference.
const marked = useMemo(() => {
const map: Record<string, any> = {};
for (const e of entries) {
const key = toDayKey(e.at);
map[key] = { marked: true, dotColor: '#fb923c' };
}
map[selected] = { ...(map[selected] ?? {}), selected: true, selectedColor: '#fb923c' };
return map;
}, [entries, selected]);
return (
<View>
<Calendar
markedDates={marked}
onDayPress={(day) => setSelected(day.dateString)} // already local YYYY-MM-DD
firstDay={1} // Monday — match the region
enableSwipeMonths
theme={{
calendarBackground: '#1c1c1c',
dayTextColor: 'rgba(255,255,255,0.85)',
monthTextColor: '#ffffff',
textDisabledColor: 'rgba(255,255,255,0.25)',
todayTextColor: '#fb923c',
arrowColor: '#fb923c',
}}
/>
</View>
);
}Two details worth keeping. onDayPress hands you a dateString that is already a local day key — use it directly instead of converting it back through a Date, which is where the off-by-one usually creeps back in. And firstDay defaults to Sunday; most of Europe expects Monday, and getting it wrong makes the whole grid feel foreign in a way users notice but rarely report.
Marking a range
Booking flows and period trackers need a filled band rather than dots. The library draws it, but you have to enumerate the days yourself — passing two endpoints does nothing:
const rangeMarks = useMemo(() => {
if (!from) return {};
const days = daysBetween(from, to ?? from);
const marks: Record<string, any> = {};
days.forEach((key, i) => {
marks[key] = {
color: '#fb923c',
textColor: '#1c1c1c',
startingDay: i === 0,
endingDay: i === days.length - 1,
};
});
return marks;
}, [from, to]);
<Calendar markingType="period" markedDates={rangeMarks} onDayPress={handleRangePress} />For a booking calendar, add the disabled days in the same pass — disabled: true plus disableTouchEvent: trueon dates that are already taken. Doing it in one memo keeps availability and selection from disagreeing, which is the usual source of “it let me book a day that was full”.
The traps
- Inline markedDates. Building the object in JSX makes a new reference on every render, so every day cell re-renders whenever any state on the screen changes. On a screen with a text input above the calendar, that is every keystroke.
- toISOString for day keys.Covered above, and worth a lint rule. If a date only ever means “a day”, store the string, not a timestamp.
- An unmemoized custom dayComponent. A custom cell defined inside the parent is a new component type every render, so React unmounts and remounts all 35 cells. Define it at module scope and wrap it in
React.memo. - Reaching for the agenda view too early. The agenda component is the heaviest thing in the package. A month grid plus a plain FlatList of the selected day’s items is lighter, easier to style, and what most apps actually want.
- Installing a calendar to pick one date.If the interaction is “when is your birthday”, the native date picker is one component, zero styling, and already familiar to the user.
One more, for anything with reminders: a day key is not a moment. When you turn '2026-09-08' back into a notification time, construct it in local time with an explicit hour rather than parsing the bare string, which most engines read as midnight UTC.
The shortcut: describe the screen, not the marking
Nothing here is hard, but all of it is fiddly, and the date handling is the kind of thing that looks correct on your machine and is wrong for a third of your users. The wiring is also identical in every app that has ever put dots on a month.
Describe the screen in ShipNative — “a month calendar where days with a logged workout show a dot, tapping a day lists that day’s workouts underneath” — and it installs the calendar, derives the marked-days map from your data model, and runs it on your phone so you can check the marking and the month swipe on real hardware. Export the full Expo project whenever you want it.
Build it free
Describe your app in one sentence and have it running on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.