skip to Main Content

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


  1. Here’s my solution.

    import React, { useState, useMemo, useCallback } from 'react';
    import { View, Text, TouchableHighlight, FlatList } from 'react-native';
    
    const CalendarDayBox = React.memo(
      ({ dayStyles, dayNumber, handleDateSelect }) => {
        return (
          <View>
            <TouchableHighlight onPress={handleDateSelect}>
              <View>
                <Text>{dayNumber}</Text>
              </View>
            </TouchableHighlight>
          </View>
        );
      }
    );
    
    const CalendarInstance = React.memo(
      ({ onViewLayout, viewWidth = 300, year, month }) => {
        // Rest of your code...
    
        const renderDaySquare = useCallback(
          ({ item }) => {
            const dayNumber = getDayNumber(item + 1);
            const dayStyles = getDayStyles(Number(dayNumber), month, year);
    
            return (
              <CalendarDayBox
                dayStyles={dayStyles}
                dayNumber={dayNumber}
                handleDateSelect={() =>
                  handleDateSelect(Number(dayNumber), month, year)
                }
              />
            );
          },
          [getDayNumber, getDayStyles, handleDateSelect, month, year]
        );
    
        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}
            >
              <FlatList
                data={[...Array(42).keys()]}
                renderItem={renderDaySquare}
                keyExtractor={(item) => item.toString()}
                numColumns={7}
              />
            </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>
    );
    
    Login or Signup to reply.
  2. One problem I can see is with this code of yours

    const handleDateSelect = useCallback(
      (day, month, year) => {
       ...
      [selectedDates]
    );
    

    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

    const handleDateSelect = useCallback((d, m, y) => {
        setDateSelect(prev => ({...prev, day: d, month: m}))
        // rest of the logic
    }, []);
    

    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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search