skip to Main Content

I have an array of events stored in my Redux state where each event is assigned to a user. I show all users stacked in a timeline with the corresponding events in the same row. For this I created a selector to grab the events for each user by userid:

export const getEvents = (state: RootState) => state.schedule.events;

export const getEventsByUser = createSelector(
  getEvents,
  (_: RootState, userId: string) => userId,
  (events, userId) => events.filter((a) => a.userId === userId)
);

I have a EventsRow component that grabs the events and shows them:

const events: EventDTO[] = useAppSelector((state) =>
  getEventsByUser(state, user)
);

Now everytime I add or delete an event all user rows are getting updated and rerendered (twice) instead of only the affected user.

  extraReducers: (builder) => {
    builder
      .addCase(postEvent.fulfilled, (state, action) => {
        const events: EventDTO[] = action.payload as EventDTO[];
        state.events = state.events.concat(events);
      })

Since there can be a lot of events rendered simultaniously this can impact performance a lot. Any way to only update the affected user row?

Here’s a minimal repro: https://stackblitz.com/edit/vitejs-vite-bajabcru?file=src%2FEvents.tsx

2

Answers


  1. One way is to normalize the data.
    You can read about it here: https://redux.js.org/tutorials/essentials/part-6-performance-normalization#normalizing-data

    Another way is to use shallowEqual. https://react-redux.js.org/api/hooks#equality-comparisons-and-updates

    // (1)
    import { shallowEqual } from 'react-redux';
    
    import { MyEvent, getEventsByUser } from './state/events.slice';
    import { useAppSelector } from './state/hooks';
    
    export function EventsRow({ user }: { user: string }) {
      console.warn('Events Row rendered for user: ' + user);
      const events: MyEvent[] = useAppSelector(
        (state) => getEventsByUser(state, user),
        // (2)
        shallowEqual
      );
    
      return (
        <div id={`user-${user}`}>
          {events.map((x) => (
            <div>{x.title}</div>
          ))}
        </div>
      );
    }
    
    
    Login or Signup to reply.
  2. Issue

    Now every time I add or delete an event all user rows are getting
    updated and rerendered (twice) instead of only the affected user.

    You updated state.schedule.events which is used in the input selector to your getEventsByUser selector, so it will obviously recompute its output value.

    export const getEvents = (state: RootState) => state.schedule.events;
    
    export const getEventsByUser = createSelector(
      getEvents, // <-- when input value changes, output value is recomputed
      (_: RootState, userId: string) => userId,
      (events, userId) => events.filter((a) => a.userId === userId)
    );
    

    Any time any of the input values to a selector update, the selector function will recompute its output value. Here, events.filter returns a new array reference.

    I assumed that since the selector should be memoized that it will not rerender the EventRow for cases where the resulting filtered array didn’t change.

    The selector value is memoized. When neither input changes, the computed memoized result is returned. It is the new array reference that is what triggers subscribers to rerender even though the filtered array value might not have actually updated.

    Solution Suggestion(s)

    Use shallowEqual utility

    You can use the shallowEqual function exported from React-Redux to do a final equality check on the selected value:

    import { shallowEqual } from 'react-redux';
    
    ...
    
    const events: MyEvent[] = useAppSelector((state) =>
      getEventsByUser(state, user),
      shallowEqual
    );
    

    See Equality Comparisons and Updates for additional details.

    Use shallowEqual utility in custom createAppSelector factory function

    You can also just incorporate the shallow reference equality check directly in the selector functions you create. See createSelector for details.

    Example:

    import {
      ....,
      createSelectorCreator,
      lruMemoize,
    } from '@reduxjs/toolkit';
    import { shallowEqual } from 'react-redux';
    import microMemoize from 'micro-memoize';
    import { RootState } from './store';
    
    const createAppSelector = createSelectorCreator({
      memoize: lruMemoize,
      argsMemoize: microMemoize,
      memoizeOptions: {
        maxSize: 10,
        equalityCheck: shallowEqual,
        resultEqualityCheck: shallowEqual,
      },
      argsMemoizeOptions: {
        isEqual: shallowEqual,
        maxSize: 10,
      },
    }).withTypes();
    
    ...
    
    export const getEventsByUser = createAppSelector(
      [getEvents, (_: RootState, userId: string) => userId],
      (assignments, user) => assignments.filter((e: MyEvent) => e.user === user)
    );
    
    const events: MyEvent[] = useAppSelector((state) =>
      getEventsByUser(state, user)
    );
    

    The above example bumps the cache size from the default of 1 to 10, and incorporates the shallowEqual utility in the equality checks.

    Additionally

    Just FYI console logging in the function component body is an unintentional side-effect and does not necessarily correlate to component renders to the DOM. Any side-effects like this should be placed in a useEffect hook. Each useEffect hook call does necessarily correlate to a render cycle.

    export function EventsRow({ user }: { user: string }) {
      const events: MyEvent[] = useAppSelector((state) =>
        getEventsByUser(state, user)
      );
    
      useEffect(() => {
        console.warn('Events Row rendered for user: ' + user);
      });
    
      return (
        <div id={`user-${user}`}>
          {events.map((x) => (
            <div key={x.title}>{x.title}</div>
          ))}
        </div>
      );
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search