Skip to content

Memoization and React

by Kent C. Dodds

abstract shapes getting pushed into cache storage

Memoization has to do with caching. Here's a super simple implementation of memoization:

const cache = {}
function addTwo(input) {
if (!cache.hasOwnProperty(input)) {
cache[input] = input + 2
}
return cache[input]
}

The basic idea is: hang on to the input and their associated output and return that output again if called with the same input.

The point is to avoid re-calculating a value for which you already have the result cached. In our case, we're avoiding "input + 2" 🙃

addTwo(3) // 5
addTwo(3) // 5, but this time we got it from the cache 🤓
// (we didn't have to recalculate it)
// 🤓 I'll show up when we've memoized something

Maybe not entirely worthwhile for this calculation, but it could/would be for an expensive one.

Another interesting aspect to memoization is the fact that the cached value you get back is the same one you got last time. So:

// let's imagine we have a function that returns an array of matching
// "post" objects:
// assuming getPostsNoMemo is not memoized
const posts1 = getPostsNoMemo('search term')
const posts2 = getPostsNoMemo('search term')
posts1 === posts2 // false (unique arrays)
// but if we memoize
// assuming getPostsMemo is memoized
const posts1 = getPostsMemo('search term')
const posts2 = getPostsMemo('search term')
posts1 === posts2 // true (identical array) 🤓

This has interesting implications for React we'll talk about in a second...

From there you need to talk about cache invalidation. If it's a pure function you're memoizing, then you could keep the cache around forever... except you might run into "out of memory" issues depending on how large the cache gets. Cache invalidation is tricky and I'm not going to get into that today.

React's memoization

React has three APIs for memoization: memo, useMemo, and useCallback. The caching strategy React has adopted has a size of 1. That is, they only keep around the most recent value of the input and result. There are various reasons for this decision, but it satisfies the primary use case for memoizing in a React context.

So for React's memoization it's more like this:

let prevInput, prevResult
function addTwo(input) {
if (input !== prevInput) {
prevResult = input + 2
}
prevInput = input
return prevResult
}

With that:

addTwo(3) // 5 is computed
addTwo(3) // 5 is returned from the cache 🤓
addTwo(2) // 4 is computed
addTwo(3) // 5 is computed

To be clear, in React's case it's not a !== comparing the prevInput. It checks equality of each prop and each dependency individually. Let's check each one:

// React.memo's `prevInput` is props and `prevResult` is react elements (JSX)
const MemoComp = React.memo(Comp)
// then, when you render it:
<MemoComp prop1="a" prop2="b" /> // renders new elements
// rerender it with the same props:
<MemoComp prop1="a" prop2="b" /> // renders previous elements 🤓
// rerender it again but with different props:
<MemoComp prop1="a" prop2="c" /> // renders new elements
// rerender it again with the same props as at first:
<MemoComp prop1="a" prop2="b" /> // renders new elements
// React.useMemo's `prevInput` is the dependency array
// and `prevResult` is whatever your function returns
const posts = React.useMemo(() => getPosts(searchTerm), [searchTerm])
// initial render with searchTerm = 'puppies':
// - getPosts is called
// - posts is a new array of posts
// rerender with searchTerm = 'puppies':
// - getPosts is *not* called
// - posts is the same as last time 🤓
// rerender with searchTerm = 'cats':
// - getPosts is called
// - posts is a new array of posts
// rerender render with searchTerm = 'puppies' (again):
// - getPosts is called
// - posts is a new array of posts
// React.useCallback's `prevInput` is the dependency array
// and `prevResult` is the function
const launch = React.useCallback(() => launchCandy({type, distance}), [
type,
distance,
])
// initial render with type = 'twix' and distance = '15m':
// - launch is equal to the callback passed to useCallback this render
// rerender with type = 'twix' and distance = '15m':
// - launch is equal to the callback passed to useCallback last render 🤓
// rerender with same type = 'twix' and distance '20m':
// - launch is equal to the callback passed to useCallback this render
// rerender with type = 'twix' and distance = '15m':
// - launch is equal to the callback passed to useCallback this render

The value of memoization in React

There are two reasons you might want to memoize something:

  1. Improve performance by avoiding expensive computations (like re-rendering expensive components or calling expensive functions)
  2. Value stability

I think we've covered the first point, but I want to make something clear about the value stability benefit. In a React context, this value stability is critical for memoization of other values as well as side-effects. Let's look at a simple example:

function App() {
const [body, setBody] = React.useState()
const [status, setStatus] = React.useState('idle')
const fetchConfig = {
method: 'POST',
body,
headers: {'content-type': 'application/json'},
}
const makeFetchRequest = () => (body ? fetch('/post', fetchConfig) : null)
React.useEffect(() => {
const promise = makeFetchRequest()
// if no promise was returned, then we didn't make a request
// so we'll exit early
if (!promise) return
setStatus('pending')
promise.then(
() => setStatus('fulfilled'),
() => setStatus('rejected'),
)
}, [makeFetchRequest])
function handleSubmit(event) {
event.preventDefault()
// get form input values
setBody(formInputValues)
}
return (
<form onSubmit={handleSubmit}>
{/* form inputs and other neat stuff... */}
</form>
)
}

Please note: this might not be the way you'd write form submission code, it's not how I'd do it either... Really, I'd just use react-query personally, but bear with me for the purpose of the example...

Take a guess at what's going to happen. If you guessed "runaway side-effect loop" you're right! The reason is because React.useEffect will trigger a call to the given effect callback whenever individual elements of the dependency array changes. Our only dependency is makeFetchRequest and makeFetchRequest is created within the component and that means it's new every render.

So this is where the value stability of memoization plays an important role in React. So let's memoize makeFetchRequest with useCallback:

const makeFetchRequest = React.useCallback(
() => (body ? fetch('/post', fetchConfig) : null),
[body, fetchConfig],
)

useCallback will only return a new function when the dependencies change. And because of that, makeFetchRequest has a stable value between renders. Unfortunately, fetchConfig is also created within the component and that means it's new every render as well. So let's memoize that for value stability:

const fetchConfig = React.useMemo(() => {
return {
method: 'POST',
body,
headers: {'content-type': 'application/json'},
}
}, [body])

Great! So now the fetchConfig and makeFetchRequest will both be stable and will only change when the body changes which is what we want. Here's the final version of this code:

function App() {
const [body, setBody] = React.useState()
const [status, setStatus] = React.useState('idle')
const fetchConfig = React.useMemo(() => {
return {
method: 'POST',
body,
headers: {'content-type': 'application/json'},
}
}, [body])
const makeFetchRequest = React.useCallback(
() => (body ? fetch('/post', fetchConfig) : null),
[body, fetchConfig],
)
React.useEffect(() => {
const promise = makeFetchRequest()
// if no promise was returned, then we didn't make a request
// so we'll exit early
if (!promise) return
setStatus('pending')
promise.then(
() => setStatus('fulfilled'),
() => setStatus('rejected'),
)
}, [makeFetchRequest])
function handleSubmit(event) {
event.preventDefault()
// get form input values
setBody(formInputValues)
}
return (
<form onSubmit={handleSubmit}>
{/* form inputs and other neat stuff... */}
</form>
)
}

The value stability provided by useCallback for makeFetchRequest helps us make sure that we can control when our side-effect runs. And the value stability provided by useMemo for fetchConfig helps us preserve memoization characteristics for makeFetchRequest so that can work.

I don't find myself having to do this stuff a whole lot, but when I need to it's nice to know how. Like I said, I'd just use react-query for this kind of thing, but if I didn't want to, this is how I would actually write this (short of abstracting it away myself):

function App() {
const [body, setBody] = React.useState()
const [status, setStatus] = React.useState('idle')
React.useEffect(() => {
// no need to do anything if we don't have a body to send
// so we'll exit early
if (!body) return
setStatus('pending')
const fetchConfig = {
method: 'POST',
body,
headers: {'content-type': 'application/json'},
}
fetch('/post', fetchConfig).then(
() => setStatus('fulfilled'),
() => setStatus('rejected'),
)
}, [body])
function handleSubmit(event) {
event.preventDefault()
// get form input values
setBody(formInputValues)
}
return (
<form onSubmit={handleSubmit}>
{/* form inputs and other neat stuff... */}
</form>
)
}

And now I don't need to worry about memoizing anything anyway! Like I said, I don't need to memoize things super often, but when I do, it's nice to know why it's needed and what I'm really doing. Hopefully I helped you understand that too. Good luck!

Get my free 7-part email course on React!

Delivered straight to your inbox.