The Big "Server Waterfall Problem" with RSCs
Understanding server-side waterfalls with RSCs and client-side waterfalls we're familiar with and why server-side waterfalls are probably better.
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) // 5addTwo(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 memoizedconst posts1 = getPostsNoMemo('search term')const posts2 = getPostsNoMemo('search term')posts1 === posts2 // false (unique arrays)// but if we memoize// assuming getPostsMemo is memoizedconst 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 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, prevResultfunction addTwo(input) { if (input !== prevInput) { prevResult = input + 2 } prevInput = input return prevResult}
With that:
addTwo(3) // 5 is computedaddTwo(3) // 5 is returned from the cache 🤓addTwo(2) // 4 is computedaddTwo(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 returnsconst 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 functionconst 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
There are two reasons you might want to memoize something:
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!
Delivered straight to your inbox.
Understanding server-side waterfalls with RSCs and client-side waterfalls we're familiar with and why server-side waterfalls are probably better.
React 19 introduces terrific primitives for building great forms. Let's dive deep on forms for the web with React.
React Server Components are going to improve the way we build web applications in a huge way... Once we nail the abstractions...
How and why you should use CSS variables (custom properties) for theming instead of React context.