Skip to main content

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:

  1. EntrantRow re-renders for any reason.
  2. The initialPrices array is recreated, meaning it now has a different reference in memory.
  3. The re-render of EntrantRow also re-renders the child component PriceButtons.
  4. PriceButtons receives the updated initialPrices.
  5. The useMemo sees the reference of initialPrices has changed and reruns its logic giving finalPrices a new reference.
  6. 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

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.

Resources