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
:
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.
(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>
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));
}