Memoising Values
Why would we want to memoise a value?
It is expensive to calculate
const Component = () => {
const value = performExpensiveCalculation();
return <View />;
};
In this contrived example value
will be calculated each time Component
re-renders. As is obvious by the function name, computing that value is an expensive operation so doing so even when the return value won't change is an opportunity for optimisation.
The value is a non-primitive dependency of something else
This could be a hook such as useMemo
or a memoised component. In these instances the value will need to be memoised so that it passes the strict equality check if its property values are unchanged
const ParentComponent = () => {
// BAD - this value is recreated between renders and will have a different reference
const entrants = [1, 2, 3];
// GOOD - this maintains the value's reference between renders
const entrants = useMemo(() => [1, 2, 3], [])
return <ChildComponent entrants={entrants} />
}
const ChildComponent = React.memo(({ entrants }) => {
return ( ... )
})
It's important to consider the costs of memoising a value. It's common for people to over-reach for something like useMemo
or useCallback
because it appears to makes sense logically that it be memoised, when in reality the cost of memoising something can outweight the gains.
const PriceButtons = () => {
const initialPrices = [5, 12, 8, 10];
return (
{initialPrices.map(price => (
<PriceButton price={price} />
))}
)
}
In this example you might be tempted to wrap initialPrices
in useMemo
, it's a non-primitive after all. So how does that look?
const PriceButtons = () => {
const initialPrices = useMemo(() => [5, 12, 8, 10], []);
return (
{initialPrices.map(price => (
<PriceButton price={price} />
))}
)
}
But the gains made to memoise that value are so minimal that the added overhead of the additional complexity, calling the useMemo
function, declaring a dependency array, doing additional property assignment etc means it isn't worth it.
It's a similar story for useCallback
. It's not uncommon to see code that looks something like this:
const PriceButtons = () => {
const [buttonPressed, setButtonPressed] = useState(false);
const onPress = useCallback(() => setButtonPressed(true), []);
return <PriceButton onPress={onPress} />;
};
The general advice is to avoid inline functions, and memoised functions are more performant, but in this example useCallback
is forcing additional computation. Not only is there a function declaration taking place, Javascript now has to allocate memory for the array dependency, React has to hold onto the reference of any values that are in that dep array, etc. If PriceButton
isn't properly optimised you're not even getting the benefit of reduced re-renders, so you've now induced a performance cost in an attempt to improve it.
To quote Kent C. Dodds:
Performance optimizations are not free. They ALWAYS come with a cost but do NOT always come with a benefit to offset that cost.
Therefore, optimize responsibly.
Identifying when a value should be memoised
It is computationally expensive
A good rule of thumb is to observe and measure, then optimise. Monitor the responsiveness of your UI, and keep an eye on the perf monitor for any frame drops across a wide range of devices. If you are seeing far more renders than necessary, the UI is chugging or renders are dropping frames, it could be time to look at memoising values to reduce that.
The goal is to minimise blocking of the Javascript thread, which is the main cause of performance issues in RN, and this involves quick non-blocking JS execution.
It's easy to overestimate just how expensive something is. Unless you are looping over an array with thousands of elements, or performing logic that generates complex object trees, what you're doing is probably not all that expensive and your performance issues lie elsewhere.
If you want to verify the execution time of this sort of logic you can measure it with a console log.
const startTimer = performance.now();
const value = performExpensiveCalculation();
const endTimer = performance.now();
console.log(`Time to compute value was ${endTimer - startTimer}ms`);
Which would output something like this, with each line being a render of the component:
Time to compute value was 36.286ms
Time to compute value was 35.8426ms
Time to compute value was 36.771ms
If the total time ends up being something fairly high, approaching or exceeding 1ms
for example, then that logic is a good candidate for memoisation because that's not time we want to incur every render. In this example that value is taking ~36ms
each render to calculate, which is a lot of time.
The next step would be to implement that memoisation and rerun your measure:
const startTimer = performance.now();
const value = useMemo(() => {
return performExpensiveCalculation();
}, []);
const endTimer = performance.now();
console.log(`Time to compute value was ${endTimer - startTimer}ms`);
Use this result to determine if you have benefited from memoisation. Also check the responsiveness of your UI and frames across both threads and observe if your issues have gone.
It's important to note that something like useMemo
doesn't improve the speed of your initial render, because that first computation has to occur in order to cache the results, but it prevents unnecessarily rerunning it each render. In the above example utilising useMemo
the outputted logs would look something like this, where the first log is the initial render and the rest are subsequent renders:
Time to compute value was 36.286ms
Time to compute value was 0.18345ms
Time to compute value was 0.1782ms
We can now justify applying that memoisation to the example earlier:
const Component = () => {
const value = useMemo(() => {
return performExpensiveCalculation();
}, []);
return <View />;
};
There is value in only running the logic inside useMemo
when necessary because it's expensive and could potentially cause performance issues when run more than necessary. By wrapping it in useMemo
we are saving the cost of computing it in every render subsequent to the initial one, and preventing that JS thread from becoming congested.
It is a non-primitive dependency of a hook
When a value is passed to the dependency array of something like useMemo
React will monitor any changes in that value to determine if the hook should re-run. If the value is an object or array without a stable reference it will trigger a re-run of that hook, even if it's not necessary. This is because JS compares non-primitive types by reference, and will consider them to have changed even if their logic has remained the same.
const EntrantRow = ({ entrantName }) => {
// Recreated each render of `PriceButtons` so its reference changes each time.
const initialPrices = [5, 12, 8, 10];
return (
<View>
<Text>{entrantName}</Text>
<PriceButtons initialPrices={initialPrices} />
</View>
);
};
const PriceButtons = ({ initialPrices }) => {
const finalPrices = useMemo(
() => initialPrices.filter((price) => price >= 10),
[initialPrices]
);
return <FlatList {...props} data={finalPrices} />;
};
Here is how this can go wrong:
EntrantRow
re-renders for any reason.- The
initialPrices
array is recreated, meaning it now has a different reference in memory. - The re-render of
EntrantRow
also re-renders the child componentPriceButtons
. PriceButtons
receives the updatedinitialPrices
.- The
useMemo
sees the reference ofinitialPrices
has changed and reruns its logic givingfinalPrices
a new reference. - The
FlatList
sees its data prop has changed and re-renders.
It's important to ensure in any place that React is running that equality comparison, non-primitive values maintain referential equality when their internal values haven't changed.
If a value doesn't depend on component props or state, it can just live in the outer scope of a component file. Here initialPrices
is a hardcoded array of numbers, it doesn't need access to anything within the closure of the PriceButtons
function, and can therefore live outside of it. Because React only calls the PriceButtons
function on re-render, anything living outside of it will maintain the same address in memory. This approach should be reached for before useMemo
or useCallback
because it avoids the additional overhead and complexity involved in those hooks.
const initialPrices = [5, 12, 8, 10];
const PriceButtons = () => {
const finalPrices = useMemo(() => sortPrices(initialPrices), [initialPrices]);
return <FlatList {...props} data={finalPrices} />;
};
It's often necessary for a value to have access to props or state within a component however, so in these scenarios it's fine to reach for useMemo
or useCallback
. When doing so however, it is crucial to ensure any dependencies are also correctly memoised from the point of instantiation. This means that a value declared three components up the tree, drilled down to a final consuming component, and passed to a hook, needs to be memoised the whole way down.
const ParentComponent = () => {
// For this example `races` is memoised correctly
const { races } = useRaceData();
const pastMeetings = useMemo(
() => races.filter((race) => wasRaceInPast(race)),
[races]
);
return <ChildComponent pastMeetings={pastMeetings} />;
};
const ChildComponent = ({ pastMeetings }) => {
// Here is the issue.
// Despite the memoisation at other levels this will be recreated each render.
const entrants = pastMeetings.map(
(meeting) => {
return meeting.entrant;
},
[pastMeetings]
);
return <GrandChildComponent entrants={entrants} />;
};
const GrandChildComponent = React.memo(({ entrants }) => {
const filteredEntrants = useMemo(() => {
return entrants.filter((entrant) => entrant.name === "Steve");
}, [entrants]);
return <FlatList data={filteredEntrants} />;
});
In the above example the ParentComponent
has correctly memoised the prop, but even though GrandChildComponent
is memoised it will still re-render because the reference of entrants
inside ChildComponent
will change each render, which will break not only the memoisation of the GrandChildComponent
prop but also the useMemo
hook. This is something to closely monitor as it's easy to overlook.
But what about functions?
Similar to objects and arrays, functions will have a different reference each render. The same logic applies to memoising functions as it does other non-primitive values. Take the example from earlier on:
const ParentComponent = () => {
const [buttonPressed, setButtonPressed] = useState(false);
const onPress = useCallback(() => setButtonPressed(true), []);
return <ChildComponent onPress={onPress} />;
};
const ChildComponent = ({ onPress }) => {
return <Button onPress={onPress} />;
};
In this example the benefit of declaring onPress
with useCallback
is basically zero, and could even be hurting more than helping performance. Where it should be used is when passing functions to optimised components that will run that equality comparison between renders. A good example is the FlatList
component.
Because FlatList
is an optimised component
its props need to have a stable reference between renders. This is a good candidate for function memoisation through useCallback
, or by just moving it outside the component.
// keyExtractor doesn't need access to component state so can live outside the component.
const keyExtractor = (item) => item.id;
const Component = ({ raceID }) => {
// The renderItem function needs access to the props, so it's wrapped in `useCallback` to maintain referential equality.
const renderItem = useCallback(
({ item }) => {
return <ListItem item={item} raceID={raceID} />;
},
[raceID]
);
return (
<FlatList {...props} renderItem={renderItem} keyExtractor={keyExtractor} />
);
};
The should I memoise?
decision tree
TL:DR
- Don't optimise prematurely - observe, measure, then optimise.
- Memoisation doesn't come for free - the benefits should outweigh the cost.
- Primitive values only need to be memoised when they're computationally expensive.
- Objects, arrays, and functions should be memoised when they are computationally expensive, a prop to a memoised component, or a in a list of dependencies.