3

I have custom created calendar using Flatlist. In the parent component I have a state with starting date and ending date and Press handler function to update state when user presses on the date. The problem is every time when I press the date render function invokes every time.

The question is: How to keep state to change, but not rerender whole calendar again and again?

Parent component with FlatList.

interface Props {
  arrivalDate: string | undefined;
  departureDate: string | undefined;
  onDayPress: (day: Date) => void;
  futureYearRange?: number;
}

const CustomCalendarList: React.FC<Props> = ({
  arrivalDate,
  departureDate,
  futureYearRange = 5,
  onDayPress,
}) => {
const months = useMonths();
const [isLoading, setIsLoading] = useState(true);
const [dates, setDates] = useState({
  endDate: arrivalDate,
  startDate: departureDate,
});

const handleDayPress= useCallback((row:IRow) => (e?: GestureResponderEvent) => {
  if (!dates.startDate || (dates.startDate && dates.endDate)) {
    setDates({endDate: undefined, startDate: row.date});
  } else {
    setDates(prevState => ({...prevState, endDate: row.date}))
  }
}, [setDates]);

const { grids, monthsToRender } = useMemo(() => {
const monthToRender = 11 - dayjs().month() + futureYearRange;
const monthsToRender: Array<{ title: string; year: number }> = [];
const grids = [];

for (let i = 0; i < monthToRender; i++) {
  const newGrid: Array<Array<IRow>> = [];
  const date = dayjs().add(i, "month");
  const daysInMonth = dayjs(date).daysInMonth();
  const monthIndex = dayjs(date).month();
  const year = dayjs(date).year();
  monthsToRender.push({ title: months[monthIndex], year });

  for (let j = 0; j < daysInMonth - 1; j++) {
    let row = [];
    // minus 1 because by default in dayjs start week day is sunday(index=0)
    let startingIndex = j === 0 ? dayjs(date).startOf("month").day() - 1 : 0;
    startingIndex = startingIndex === -1 ? 6 : startingIndex;

    for (let k = startingIndex; k < 7; k++) {
      if (!(j + 1 > daysInMonth)) {
        row[k] = {
          day: j + 1,
          date: dayjs(date)
            .date(j + 1)
            .format("YYYY-MM-DD"),
        };
      }
      if (k === 6) {
        newGrid.push(row);
      } else {
        j += 1;
      }
    }
  }
  grids.push(newGrid);
};
console.log('generated')
return {
  grids,
  monthsToRender
};
}, [futureYearRange]);


const renderItem = useCallback(({
  item,
  index,
}: ListRenderItemInfo<Array<Array<IRow>>>) => {
  return (
    <Grid 
      onPress={handleDayPress}
      monthsToRender={monthsToRender} 
      grid={item} 
      gridIndex={index} 
    />
  );
}, [dates.startDate, dates.endDate]);

useEffect(() => {
  const timeoutId = setTimeout(() => {
    setIsLoading(false);
  }, 300);
  return () => {
    clearTimeout(timeoutId);
  };
}, []);

if (isLoading) {
  return (
    <View
      style={css`
      height: 90%;
      justify-content: center;
      align-items: center;
      background: ${colors.primaryBg};
    `}
  >
    <ActivityIndicator color={"blue"} size="large" />
  </View>
);
}

return (
  <Calendar>
    <FlatList 
      data={grids} 
      showsVerticalScrollIndicator={false}
      updateCellsBatchingPeriod={1000}
      renderItem={renderItem} 
      maxToRenderPerBatch={3}
      keyExtractor={() => uuidv4()}
    />
  </Calendar>
);
};

enter image description here

Exoriri
  • 327
  • 3
  • 21
  • "Press handler function to update state when user presses on the date. The problem is every time when I press the date render function invokes". What do you mean? First sentence: Im updating the state when the user presses. ok. Second Sentence: I don't want the Calendar to rerender... This is intended to be. Updating states will cause a rerender – Maximilian Dietel Apr 08 '22 at 16:57
  • Yeah, so that is a question: is there is any ways to render only necessary part. So, day or month only not whole calendar. – Exoriri Apr 09 '22 at 08:15

3 Answers3

2

Issue

You are generating new React keys each time the component renders.

<FlatList 
  data={grids} 
  showsVerticalScrollIndicator={false}
  updateCellsBatchingPeriod={1000}
  renderItem={renderItem} 
  maxToRenderPerBatch={3}
  keyExtractor={() => uuidv4()} // <-- new React key each render cycle!
/>

With non-stable keys React assumes these are all new elements and need to be mounted and rendered. Using the array index would be a better solution (don't do that though!!).

Solution

Add the generated GUID as a property that can then be extracted when rendering.

Example:

const { grids, monthsToRender } = useMemo(() => {
  ...
  const grids = [];

  for (let i = 0; i < monthToRender; i++) {
    ...

    for (let j = 0; j < daysInMonth - 1; j++) {
      ...

      for (let k = startingIndex; k < 7; k++) {
        if (!(j + 1 > daysInMonth)) {
          row[k] = {
            guid: uuidV4(), // <-- generate here
            day: j + 1,
            date: dayjs(date)
              .date(j + 1)
              .format("YYYY-MM-DD")
          };
        }
        if (k === 6) {
          newGrid.push(row);
        } else {
          j += 1;
        }
      }
    }
    grids.push(newGrid);
  }

  return {
    grids,
    monthsToRender
  };
}, [futureYearRange]);

...

<FlatList 
  data={grids} 
  showsVerticalScrollIndicator={false}
  updateCellsBatchingPeriod={1000}
  renderItem={renderItem} 
  maxToRenderPerBatch={3}
  keyExtractor={({ guid }) => guid} // <-- extract here
/>
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Great solution. I totally missed that. You could also skip the uuid generation and just use the `date` property which is already formatted as yyyy-mm-dd. This would not only save some CPU cycles, it would survive multiple runs of the memo as the same day will always get the same `date` value: `keyExtractor={({ date }) => date}` – Ryan Wheale Apr 09 '22 at 08:53
  • unfortunately, it doesn't work. But thanks for the answer. Rerender happens because when state in parent component changes child components render again. I'm trying to solve it using context but attempts don't work also. – Exoriri Apr 09 '22 at 14:00
  • @Exoriri React components rerender when their parent component rerenders, so this is, or should be, expected behavior. What, or how, are you determining "rerenders" and is this even an issue? By issue I mean, have you observed/measured an *actual* performance issue? – Drew Reese Apr 09 '22 at 23:38
  • @Exoriri If you don't mind, could you provide a *running* codesandbox that reproduces the rendering issue that we could inspect and debug live? Can you also include clear and concise steps to reproduce the issue? – Drew Reese Apr 09 '22 at 23:46
  • @DrewReese Yes, it is an issue. I determine it by 2 ways. Looking to an interface when I click on the date, `handleDayPress` updates the state, so `FlatList` rerenders. Also, I write `console.log` in `renderItem` function and again I click on the date, `handleDayPress` works and there long stack trace created from `console.log` Okay, I will try to resolve issue by myself using mobx. If it doesn't work, I will provide sandbox. Thanks again for help! – Exoriri Apr 10 '22 at 07:54
  • @Exoriri Ok, I'll wait for word back. I was taking another look at the `grids` value earlier and I think part of the issue is that when either of the dependencies for the `useMemo` hook update a new `grids` array is created (*this is expected*) but then all new rows and row id's are created. Maybe this isn't an issue if/when using dates though. I typically avoid dates as keys/GUID's mostly because they tend to not be unique enough for key/GUID use. – Drew Reese Apr 10 '22 at 08:07
  • @DrewReese, Hello, sorry for a delay. I tried to solve it with mobx, but it didn't work. I provide a sandbox with calendar sample. https://codesandbox.io/s/blue-breeze-qyub7y?file=/src/CustomCalendar/index.tsx When you click on the date, you'll see freezing. Because state of parent component changes, it also renders again whole component. Tried to solve it with context also, but still didn't find solution how to do that. – Exoriri Apr 16 '22 at 12:34
  • @Exoriri Unfortunately I don't see any freezing when running your sandbox. It's also not entirely clear what all the code is supposed to be doing. From what I can tell it seems it's only computing the "day" label to render and a formatted date string. The triple-nested loop seems an awful lot just for this. Can you clarify what the use case is overall for this code? – Drew Reese Apr 18 '22 at 17:29
  • @DrewReese, Wow, but I see freezing in browser it's less than in emulator, but still exist. It must just render calendar and select dates. Calendar-range-picker. Yeah, triple-nested loop seems really awful. It just solution what I came up with. Today I also came up with one idea to write some checks with `React.memo` in `SelectableRowList.tsx`. But thanks for an answering. – Exoriri Apr 18 '22 at 21:30
  • @Exoriri Do you just need to have a list of dates, and then select/render a subset of those for the "next months to render"? Is the real state just the selected date in the calendar? – Drew Reese Apr 18 '22 at 21:37
  • @DrewReese Real state is a bit different. Yes, I need to mark subset of dates. There is a `startDate` and `endDate` and I need to mark subset between these two dates – Exoriri Apr 18 '22 at 22:59
  • @Exoriri I suppose I'm driving towards the point that it's a terrible idea to recompute values that effectively never change. April 18 will always be April 18, no matter what dates around it are selected. If this is the case for your application I can try tweaking your Snack's code a bit. Can you confirm? – Drew Reese Apr 18 '22 at 23:04
  • @DrewReese yeah, of course. – Exoriri Apr 19 '22 at 10:46
0

I think it might have to do with the dependencies you are defining for some of your useCallback hooks. The second parameter to useCallback should be an array of variables you reference inside the hook. For example:

const foo = Math.random() > 0.5 ? 'bar' : 'bing';
const someCallback = useCallback(() => { 
  if (foo === 'bar') { ... }
}, [foo]); // you must list "foo" here because it's referenced in the callback

There is one exception: state setters are not required as they are guaranteed to never change::

React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.
https://reactjs.org/docs/hooks-reference.html#usestate

const [foo, setFoo] = useState('bar');
const someCallback = useCallback(() => { 
  setFoo('bing');
}, []); // there is no need to put `setFoo` here - WOOHOO!

Fixing your code:

You have several situations where you are not listing all of the referenced variables, which can get you into a very undetermined situation.

  • handleDayPress - you can remove setDates from the dependency list and add dates.

    const handleDayPress= useCallback(..., [dates]);
    
  • The memo looks good! It only needs futureYearRange

  • renderItem - remove the current depencies and add handleDayPress and monthsToRender

    const renderItem = useCallback(..., [handleDayPress, monthsToRender]);
    
Ryan Wheale
  • 26,022
  • 8
  • 76
  • 96
  • Wow, thanks for an answer. Didn't know that it's not required to pass useState setters to dependencies list. I'll try what you've written – Exoriri Apr 09 '22 at 08:06
  • Didn't work. I think need to make `dates` to work from context and get it only from necessary parts of `Grid` component – Exoriri Apr 09 '22 at 08:24
0

So, one of the way to solve a problem is to check props via React.memo in Grid component to prevent unnecessary months render. Grid component in my case is whole month to render, so if I check for startDate or endDate in a month it will render only that months.

The problem is only when user wants to select the startDate and endDate within 5+ months difference. There will cause 5 renders again. Let's imagine if it will be 10+ months difference, it will start freezing.

Exoriri
  • 327
  • 3
  • 21