I’m attempting to re-create Airbnb’s IOS calendar (date range picker) with React Native.
I’m running into a performance issue where I’m noticing that the animation of things such as TouchableHighlight or TouchableOpacity are being blocked by all the day squares in the calendar re-rendering. Meaning, there is a delay after you click for the highlight/opacity change to happen.
I’ve tried using useCallback()
and memo()
to attempt to eliminate re-renders but I can’t quite seem to do it.
I’ve created a snack – please use the IOS simulator as thats why I’m focusing on right now.
How can I improve render performance and stop all the squares that weren’t pressed/clicked from re-rendering?
And heres the code:
The CalendarDayBox (individual day sqaures)
const CalendarDayBox = React.memo(
({
dayStyles,
month,
year,
idx,
boxWidth,
isWithinRange,
dayNumber,
isStartOrEndDate,
handleDateSelect,
}) => {
return (
<View
style={{
justifyContent: "center",
alignItems: "center",
flexGrow: 1,
flexBasis: boxWidth,
height: boxWidth,
marginBottom: 1,
position: "relative",
}}
>
<View
style={{
opacity: isWithinRange(Number(dayNumber), month, year) ? 1 : 0,
position: "absolute",
backgroundColor: "gray",
height: boxWidth - 10,
width: `${boxWidth + 1}px`,
...isStartOrEndDate(Number(dayNumber), month, year, idx),
}}
></View>
<TouchableHighlight
disabled={dayNumber === null}
onPress={() => handleDateSelect(Number(dayNumber), month, year)}
>
<View
style={{
borderRadius: 50,
width: boxWidth - 10,
height: boxWidth - 10,
justifyContent: "center",
alignItems: "center",
backgroundColor: dayStyles.backgroundColor,
}}
>
<Text
style={{
textAlign: "center",
color: dayStyles.textColor,
lineHeight: 0,
fontSize: 10,
fontWeight: "bold",
}}
>
{dayNumber}
</Text>
</View>
</TouchableHighlight>
</View>
);
}
);
Main calendar component
const CalendarInstance = React.memo(
({ onViewLayout, viewWidth = 300, year, month }) => {
const dayOffset = useMemo(() => {
const firstDayOfMonth = new Date(year, month, 1 - 1);
const dayIndex = firstDayOfMonth.getDay();
return dayIndex;
}, [year, month]);
const daysInMonth = useMemo(() => {
if (month === 1) {
return 28;
}
if ([3, 5, 8, 10].includes(month)) {
return 30;
}
return 31;
}, [month]);
const getMonthName = useCallback((month) => {
const monthNames = {
0: 'January',
1: 'February',
2: 'March',
3: 'April',
4: 'May',
5: 'June',
6: 'July',
7: 'August',
8: 'September',
9: 'October',
10: 'November',
11: 'December',
};
return monthNames[month];
}, []);
const getDayNumber = useCallback(
(day) => {
if (day <= dayOffset) {
return null;
}
if (day > daysInMonth + dayOffset) {
return null;
}
return day - dayOffset;
},
[dayOffset, daysInMonth]
);
const [selectedDates, setSelectedDates] = useState({
start: null,
end: null,
});
const { width: screenWidth } = useWindowDimensions();
const containerWidth = Math.floor(screenWidth - 40);
const boxWidth = Math.floor(containerWidth / 7);
const handleDateSelect = useCallback(
(day, month, year) => {
const date = format(new Date(year, month, day), 'yyyy-MM-dd');
if (!selectedDates.start) {
setSelectedDates({ ...selectedDates, start: date });
} else if (!selectedDates.end) {
setSelectedDates({ ...selectedDates, end: date });
} else {
setSelectedDates({ start: date, end: null });
}
},
[selectedDates]
);
const getDayStyles = useCallback(
(day, month, year) => {
const date = format(new Date(year, month, day), 'yyyy-MM-dd');
if (date === selectedDates.start || date === selectedDates.end) {
return {
backgroundColor: 'black',
textColor: 'white',
};
}
if (selectedDates.start && selectedDates.end) {
if (date > selectedDates.start && date < selectedDates.end) {
return {
backgroundColor: 'grey',
textColor: 'black',
};
}
}
return {
backgroundColor: 'transparent',
textColor: 'black',
};
},
[selectedDates]
);
const isWithinRange = useCallback(
(day, month, year) => {
const date = format(new Date(year, month, day), 'yyyy-MM-dd');
if (date === selectedDates.start || date === selectedDates.end) {
return true;
} else if (selectedDates.start && selectedDates.end) {
if (date > selectedDates.start && date < selectedDates.end) {
return true;
}
if (date < selectedDates.start && date > selectedDates.end) {
return true;
}
}
return false;
},
[selectedDates]
);
const isStartOrEndDate = useCallback(
(day, month, year, idx) => {
const date = format(new Date(year, month, day), 'yyyy-MM-dd');
const isStartDate = date === selectedDates.start;
const isEndDate = date === selectedDates.end;
const startOfRow = [0, 7, 14, 21, 28, 35].includes(idx);
const endOfRow = (day + dayOffset) % 7 === 0;
if (!selectedDates.start || !selectedDates.end) {
return {
opacity: 0,
};
}
if (!isEndDate && !isStartDate) {
return {
borderTopLeftRadius: startOfRow ? 10 : 0,
borderBottomLeftRadius: startOfRow ? 10 : 0,
borderTopRightRadius: endOfRow ? 10 : 0,
borderBottomRightRadius: endOfRow ? 10 : 0,
};
}
const rangeReversed = selectedDates.start > selectedDates.end;
if (isStartDate && rangeReversed) {
return {
left: '-10%',
borderTopRightRadius: 50,
borderBottomRightRadius: 50,
};
} else if (isEndDate && rangeReversed) {
return {
left: '10%',
borderTopLeftRadius: 50,
borderBottomLeftRadius: 50,
};
} else if (isStartDate) {
return {
left: '10%',
borderTopLeftRadius: 50,
borderBottomLeftRadius: 50,
};
} else if (isEndDate) {
return {
left: '-10%',
borderTopRightRadius: 50,
borderBottomRightRadius: 50,
};
}
},
[selectedDates, dayOffset]
);
return (
<View style={{ paddingHorizontal: 5 }}>
<Text
style={{ fontSize: 16, fontWeight: 'bold', marginBottom: 2 }}
onPress={() => setSelectedDates({ start: null, end: null })}>
{getMonthName(month)} {year}
</Text>
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
width: containerWidth,
marginHorizontal: 'auto',
overflow: 'hidden',
}}
onLayout={onViewLayout}>
{[...Array(42)].map((_, idx) => {
const dayNumber = getDayNumber(idx + 1);
const dayStyles = getDayStyles(Number(dayNumber), month, year);
return (
<CalendarDayBox
key={idx}
dayStyles={dayStyles}
month={month}
year={year}
idx={idx}
boxWidth={boxWidth}
isWithinRange={isWithinRange}
dayNumber={dayNumber}
isStartOrEndDate={isStartOrEndDate}
handleDateSelect={handleDateSelect}
/>
);
})}
</View>
<View>
<Text>start: {selectedDates.start ?? ''}</Text>
<Text>end: {selectedDates.end ?? ''}</Text>
</View>
</View>
);
}
);
const App = () => <SafeAreaView style={{flex:1}}><CalendarInstance month={6} year={2023} /></SafeAreaView>;
2
Answers
Here’s my solution.
One problem I can see is with this code of yours
Since your dependency array has selectedDates as dependency. Every time selectedDates changes there’s new reference of handleDateSelect which will re-render CalenderDayBox even if you have memo on it. Memo can only prevent re-rendering when props are same in your case it would be different every time selectedDates change.
Solution: instead of relying on selectedDates you can use something like
If you use this way no relying on selectedDates and ref for it will not change. Similarly you can look out for other unnecessary re-rendering in your code.