Search code examples
reactjsreduxreact-reduxelectrongoogle-chrome-devtools

Why is so much time spent in scripting when rendering list items in React? (performance optimisation)


I've been trying to improve the performance of my React app (Electron to be specific) which uses react-redux and reselect. I have a parent (grid) component which gets some data from the redux store using useSelector, and for each item in the array renders a child component (row in the grid). We also have a filter functionality, so we do some transformation on the array of product data. Something along these lines:

 const data = useDataSelector(
   "all",
   categoryId || EVERYTHING_CATEGORY_ID
 );
 const location = useLocation();
 const [filteredData, setFilteredData] = useState([]);

 useEffect(() => {
    if (query) {
      setFilteredData(
        fuse.search(`${query}`).map((product) => product.item)
      );
    } else {
      setFilteredData(data);
    }
  }, [
    location.search,
  ])

 return (
   <>
     {data.map((productInfo) => (
            <Row key={productInfo.id} {...productInfo} />
          ))}
   </>
 );

useDataSelector calls useSelector:

export const useDataSelector = (statusType: StatusType = "all", categoryId) =>
  useSelector(productSelector(statusType, categoryId));

and productSelector is a memoized selector that does some pretty heavy computation:

const productSelector = (
  statusType: StatusType,
  categoryId: number,
) =>
  createSelector(
    [selectProductData, selectProductStatus],
    (productData, productStatus) =>
    //some pretty heavy computations here
  )

Now the issue I am seeing is the fact that rendering the grid component is super slow. I can't really see much when recording performance except for the fact that we're spending a very long time scripting:

enter image description here

I can't see much when looking at the Call Tree or Event Log tabs. It seems like death by a thousand papercuts... Is this normal? It's rather disappointing how janky our scroll is.

enter image description here

(regarding the screenshot above: we've added LazyLoad to reduce load time when we switch the tabs or when the app is loaded). This guy sets up scroll event listener and renders more components when needed.

<li>
  <LazyLoad
        height={60}
        scrollContainer={scrollContainer}
        offset={500}
        overflow
        once
  >
    // actual component rendering
  </LazyLoad>
</li>

Solution

  • As explained by @Zachary Haber in the comments, you aren't getting the full benefit of createSelector because you are creating the memoized selector through a function which gets re-executed on every render.

    You can actually pass arguments through your input selectors, though it's annoying because you have two arguments (and we don't want to combine them into an object without memoizing that object).

    You can move the productSelector function into the useDataSelector hook and wrap it in useMemo such that it is only re-evaluated when statusType or categoryId change.

    import { createSelector } from '@reduxjs/toolkit'; // or from 'reselect'
    import { useMemo } from 'react';
    import { useSelector } from 'react-redux';
    
    export const useDataSelector = (statusType: StatusType = "all", categoryId: number): Product[] => {
      const productSelector = useMemo(() => {
        return createSelector(
          [selectProductData, selectProductStatus],
          (productData, productStatus): Product[] => {
            return heavyComputation(productData, productStatus, statusType, categoryId);
          }
        )
      },
        [statusType, categoryId]
      );
    
      return useSelector(productSelector);
    }
    

    If someone visits a categoryId then goes to another one then comes back to the first, it will re-evaluate with this approach. Whereas something like re-reselect would keep the previously-computed selectors in its cache.

    Also note that if you have multiple components using useDataSelector they will each create their own cached version of the selector. I am assuming here that the list is the only place which accesses the data and that all child components get their data passed down via props.

    Since re-reselect solves these issue, let's see how we can use it.

    import { useSelector } from 'react-redux';
    import { createCachedSelector } from 're-reselect';
    
    const productSelector = createCachedSelector(
      // select from redux state
      selectProductData,
      selectProductStatus,
      // pass through arguments
      (state: RootState, statusType: StatusType) => statusType,
      (state: RootState, statusType: StatusType, categoryId: number) => categoryId,
      // combine the results
      (productData, productStatus, statusType, categoryId): Product[] => {
        return heavyComputation(productData, productStatus, statusType, categoryId);
      }
    )(
      // create a unique cache key from our arguments
      (state, statusType, categoryId) => `${statusType}__${categoryId}`
    )
    
    export const useDataSelector = (statusType: StatusType = "all", categoryId: number): Product[] => {
      return useSelector((state: RootState) => productSelector(state, statusType, categoryId));
    }
    

    TypeScript Playground