Mastering React’s Stable Values

The concept of stable value is a distinctly React term, and especially relevant since the introduction of Functional Components. It refers to values (usually coming from a hook) that have the same value across multiple renders. And they’re immediately confusing (as you can see in the gist below), so in this post we’ll walk through some cases where they really matter and how to make sense of them.

  1. useState and useReducer return a state update function that is constant – the hook will always return the same function. The value is guaranteed to be stable, because we know that the same object will be returned until we call the state update function.
  2. useRef returns an object that is guaranteed to be constant. You can change the ref.current value without triggering a re-render.
  3. Creating an array literal seems innocent enough…but this is unstable! A new array instance will be created on every render. This is fine from a performance standpoint (unless you’re inserting millions of items in the array), but if you pass the array to a function that expects a stable value… there be dragons!
  4. Similarly for anonymous functions and object literals. It’s obvious once you see it, but many React developers are surprised when a rendering issue can be tracked down to an anonymous function being passed to an onPress prop.
  5. To create a stable value, useCallback and useMemo are your friends.

From this example we can come up with a definition of stability in React: “stability” is the conditions which will result in a change to a value. A value is stable when it doesn’t change on every render – countsetCount, ref, and onPress are all “stable” under this definition. A value is unstable when it changes on every render – countsArray and func are unstable.

It might not be clear that these are all related to this problem of stability, so we’ll unpack these cases one at a time:

  1. Functions that expect stable values (usually dependencies, but also useFocusEffect)
  2. Inline functions that return components (which is a bit of an anti-pattern, but common enough to talk about)
  3. Component memoization

Functions That Expect Stable Values 

The first place you're likely to come across the importance of stable values are the React hooks useEffect, useCallback, and useMemo. All these functions accept a list of dependencies. The expectation is that these hooks’ behaviors are tied to how the values in the dependency list change. If any of those values are unstable (if they change on every render), then we'll have undesired behavior. For instance, the cached value returned by useCallback``/useMemo will never be reused if your dependency list includes an unstable value, and your useEffect function will be called on every render.

Another way of thinking about these hooks is that they give us another way to generate stable values. If we have a source of stable values we can generate a new stable value using useMemo or useCallback, or we can respond to specific state changes inside useEffect.

Identifying APIs that expect a stable value can be subtle to notice. One such surprising API that almost every React Native developer has to learn about is useFocusEffect from React Navigation. You’d probably expect this function to work the same as its namesake, useEffect, but as the React Navigation docs very gently point out:

“Note: To avoid running the effect too often, it’s important to wrap the callback in useCallback before passing it to useFocusEffect as shown in the example.”

Let’s unpack this. On every focus event, we’ll subscribe to API events related to the userId. If we go to another screen, the unsubscribe function (aka cleanup function in the context of useEffect, which has similar behavior) is called. What about the [userId] dependency? It acts like a blur-then-focus event, in that if the userId value changes, first the unsubscribe function is called, and then immediately the focus effect (API.subscribe). If we want API.subscribe to be called exactly once, we could wrap userId in a useRef and treat it as a constant—but we won’t get into that in this post.

The incorrect code, for completeness sake, is dangerously easy to write:

I also want to point out a common false belief around useCallback: It doesn’t save on memory! The function passed to useCallback isn’t magically compiled away, it’s still created as if the hook wasn’t there at all, and it’s just ignored if the dependencies haven’t changed. This is in slight contrast to useMemo that accepts a function and only invokes that function when a new value is needed (and did you notice? In both cases we have a function that might be ignored—regardless of memory.)

I’m going to be extra verbose here in explaining why useFocusEffect works the way it does. useFocusEffect has no way to know whether the function being provided is the same or not—every time this component renders, a new anonymous function is created. This new function closes over whatever local variables it needs—are these the same values as the last time the function was created? Possibly?

So useFocusEffect has to assume that this new function’s behavior is different and what it does (on every render) is call the cleanup function unsubscribe. So on every render this event is unsubscribed and resubscribed. Is this broken? Nope! Not broken, it would work fine. But it’s not what was intended.

Inline Functions That Return Components

I promise the rest of my examples are much more straightforward. Imagine you have a component that either shows some details or a show more button. I’m going to use an inline function that returns a component here—not my favorite pattern, but you’re almost sure to come across it, and I want to point out a pitfall. In this code, the details function will return different JSX depending on showMore. The pitfall is in how we tell React to render the JSX.

We have two ways to call our function, and insert the result into the component’s JSX. The easiest way is, luckily, the correct way to handle this situation:

But we can also treat the function as a component and render it as JSX. This accidentally has terrible performance! 

Subtle, right? In the correct version (I do find fault with the aesthetics, we certainly didn’t need to wrap the logic in a function at all), we invoke our helper that inserts the resulting JSX directly into our return value. That's what is passed to React for comparison. In the version with terrible performance, <details> is being treated as a component, and so React will compare it as a component. What does that mean?

The function details is unstable. So between renders, we’re guaranteed to have a function that looks like an entirely new component. React treats these two functions the same way it compares <Apple...Apple> and <Orange...Orange>. It destroys all the child components, creates a new component tree (in React Native this means creating native View objects, which are rather expensive to create), and mounts the whole thing. On every render.

Other ways to render details() while still avoiding the performance pitfall of the second approach:

You could memoize the return value of details, but you might not get the rendering performance you expect. Memoizing components with useMemo is not the same as memoizing with React.memo, you'll still need to pay attention to re-renders, but the React reconciler will still be able to detect changes.

Or better yet, if it looks like a component and walks like a component...why not just make it a component. Later if you need to optimize further (with React.memo or what have you) you can use tried and tested ways of profiling your component and improving the performance.

A rule to walk away with: components have to live in the global space. They do no one any good if they’re created within the body of another component. Why is this? Because if you place function Toggle({...}) inside the body of function Overview(), you’re creating a new function instance on every render, and React cannot make any assumptions on whether the new component has anything in common with the component created during a prior render.

Component Memoization (But Don’t Call It That) 

Last but not least, we come to component memoization. I bring this one up because when we ask ourselves "should I wrap this _onPress_ with _useCallback_?" The thing we’re really asking is “am I passing this function to a memoized component?”

Note: Memoized, by the way, is technically (adjusts glasses) not the right term. They’re cached with a cache size of 1, and the cache is rejected when any props change. Likewise for useMemo. If you search for “react memoization” you’ll find many, many people claiming these functions “memoize.” Now you can smugly (or, you know, politely) correct all these people. Join me on this lonely pedestal.

If you’re pushing a callback to a component that’s been wrapped in React.memo, you’ll get no benefit if that callback is unstable. This isn’t a carte blanche to wrap every event handler in useCallback. You should be judicious.

So there you have it! Some thoughts and opinions on stable values, how they work, why they're important. If you’re a React developer, I hope this helps you out. 

More on React

Colin Gray is a Principal Developer of Mobile working on Shopify’s Point of Sale application. He has been writing mobile applications since 2010, in Objective-C, RubyMotion, Swift, Kotlin, and now React Native. He focuses on stability, performance, and making witty rejoinders in engineering meetings. Reach out on LinkedIn to discuss mobile opportunities at Shopify!

If building systems from the ground up to solve real-world problems interests you, our Engineering blog has stories about other challenges we have encountered. Visit our Engineering career page to find out about our open positions. Join our remote team and work (almost) anywhere. Learn about how we’re hiring to design the future together—a future that is digital by design.

Home - Wiki
Copyright © 2011-2024 iteam. Current version is 2.137.1. UTC+08:00, 2024-11-05 16:24
浙ICP备14020137号-1 $Map of visitor$