Concept primer

Referential stability 1/5: useExperiment

Explaining a subtle React concept before asking anybody to go and apply it.

This is a pull request I opened at InterNations. It is laid out below the way my colleagues would have come across it: as a review page, at the point where they had to decide whether to take the change on.

The description is the part on display here. The diff that went with it is private, and links to the repository and to our internal tools are flattened to plain text, so nothing below will take you somewhere it shouldn’t.

Referential stability 1/5: useExperiment#6927

Mergedpatik merged this pull request after 3 reviews
ConversationCommitsFiles changed 8+510−270
patik commented on 24 Mar 2024
Referential stability PRs

This is part of a series of PRs on referential stability which are meant to be reviewed in the following order:

  1. useExperiment PR #6927
  2. useDialog PR #6928
  3. Navigation PR #6930
  4. Global files PR #6929
  5. PXT-specific
    • Tetris: PR #6935
    • Pokemon: PR #6936
    • Rings: PR #6937

Overview of changes

  • Improves memoization within useExperiment and makes its return value referentially stable
  • useDiff: new debugging tool for testing changes between renders
  • useStableReference: new convenience wrapper for useRef

Crash course in “referential stability”

See also this more in-depth guide

Consider this example:

const foo = getSomeString()
useEffect(() => {
// do something with foo
}, [foo])

The useEffect will only run when foo gets a different value. So if it’s “hello” in the first render and “hello” in the second render, useEffect will not re-run. This is because strings are primitive values.

But this changes when we use an object or array as a dependency:

const foo = {}
useEffect(() => {
// do something with foo
}, [foo])

In this case useEffect will re-run every single time, even though foo is always assigned the “same” value. This is because the expression const foo creates a new variable in memory. The foo from the first render is located in a different part of memory than the foo from the second render. When React evaluates the dependencies of useEffect, it considers foo to have changed if it’s pointed to a different place in memory.

Two renders each building their own array in memory: the contents are equal but the references are not

To get around this, we can take care to ensure that our objects and arrays are always stored in the same part of memory.

const foo = {}
function Component() {
const bar = useMemo(() => ({}), [])
useEffect(() => {
// do something with foo and bar
}, [foo, bar])
}

The useEffect above will only run once. foo is referentially stable because it’s outside of a component and will be defined only once when the file is first imported. bar is referentially stable because useMemo will always give us back a value that points to the same place in memory. We can also do this with useCallback.

Why bother with this? If foo is not referentially stable, and our component is rendered 1,000,000 times, we would have 1,000,000 copies of foo in memory. If it is referentially stable, then we only have 1 foo in memory. So we get lower memory usage and fewer re-renders—and those are both critical problems with the SPA at the moment.

useExperiment

This hook is used a lot in our app. And most of the time, the actual values—the user’s segment for each experiment—doesn’t actually change after the initial load. It also has a lot more internal logic than most query hooks, e.g. to create convenience booleans for the segments. This makes it a good candidate for optimization.

I had these goals in mind while working on it:

  • Ensure that dependency arrays for useMemo, useEffect, etc only contain primitive values and referentially stable objects/arrays
  • Ensure the final return value is referentially stable
  • Consolidate the logic around the options object into one place

useDiff

useDiff is a hook that can be used during development to check if a value is referentially stable. You can pass it a value and it will log to the console on each render to tell you if the value is stable. If it’s not, the hook will tell you what changed.

useDiff('display name', myValue)

Console output from useDiff showing when the experiment object stays stable and when its properties change

useStableReference

This is a convenience wrapper around useRef that creates a fixed reference for an arbitrary value. It’s useful in places where we want to have a stable reference but cannot create one, e.g. if a library always returns a new object.

const unstableQueryResult = useQuery(...)
const stableResult = useStableReference(unstableQueryResult)

Proof of concept

I made a demo page to test this out. Specifically, it shows that the return value from useExperiment is stable, however the visual components continue to update with new data.

Click on a video to zoom in, pause, etc

BeforeAfter
Animated demo before stabilizing the useExperiment return valueAnimated demo after stabilizing the useExperiment return value

What’s next

This is the first in a series of PRs that aims to add memoization and stable references in places where we need it. There is only one changed hook in this PR so that we can all focus on it and ensure we understand what’s going on. Subsequent PRs will add memo/ref-stab to more and more places.