Despite over a decade of using React, there is one bug I still encounter almost daily.
To demonstrate, here's a question:
import { useQuery } from './hooks'
function Component {
const { foo } = useQuery()
const bar = useQuery()
useEffect(() => console.log('foo changed'), [foo])
useEffect(() => console.log('bar changed'), [bar])
return null
}
Every time Component
renders, do those effects fire?
In other words, what is referentially stable here across React renders?
- Does
useQuery
return an object with stable keyfoo
? You would assume that it would, but it might not! - Does
useQuery
return a stable objectbar
? Merely due to React convention, you wouldn't assume so, but it might! - Does
useQuery
return a stable object with stable values inside it? It could!
How would you know? Either read the source or see what happens at runtime.
Now let's make it harder:
import { useQuery } from './hooks'
function Component {
const onCompleted = () => console.log('completed')
const { foo } = useQuery({ onCompleted })
const options = { onCompleted: () => console.log('completed') }
const bar = useQuery(options)
useEffect(() => console.log('foo changed'), [foo])
useEffect(() => console.log('bar changed'), [bar])
return null
}
Now what?
Not only do you have to answer the first questions, but there are new questions:
- Does
useQuery
expect a stableonCompleted
function? - Does
useQuery
let you pass in an unstableonCompleted
function as a courtesy but hacks around it internally so that the last value ofonCompleted
is used when something completes inside it? - Does
useQuery
expect a stableoptions
object or just stable values? - Does anything change if you get this wrong? If
onCompleted
changes on every render, willuseQuery
also produce a newfoo
orbar
on every render?
What do we have to "stabilize" with useMemo
and useCallback
? Anything? Nothing?
Once again, these are questions you can't answer without reading the source. We tend to rely on weak conventions to assume runtime behavior and when we're wrong we'll just find out at runtime, won't we?
This isn't specific to hooks, either.
function Component() {
const [text, setText] = useState('')
const callback = () => {};
return <>
<Nested callback={callback} />
<input value={text} onChange={e => setText(e.target.value)}>
</>;
}
Does Nested
expect a stable prop here? Does it matter?
It can matter! And we better find out because callback
is going to change on every keystroke into the input box.
Consider this:
function Nested(props: { callback: () => void }) {
useEffect(() => {
console.log('connecting to websocket server)
callback()
return () => {
console.log('disconnecting to websocket server')
}
}, [callback])
return null
}
It turns out Nested
has quite an expensive operation that happens every time its callback
prop changes: it cycles a connection to a websocket server.
Without eternal vigilance, it's trivial to mess up these examples.
And you don't catch it until, say, you open the browser network panel and see websocket connection spam when you finally test out that input box and wonder what the heck is going on.
So, what can we do about these problems?
I'm thinking maybe all we need is a Stable<T>
type that will communicate these expectations.
type Stable<T> = T & { __stable: never };
Better yet, primitive values should be stable without having to mark them as stable:
export type Stable<T> = T extends
| string
| number
| boolean
| null
| undefined
| symbol
| bigint
? T
: T & { __stable: never };
Now we can patch React to declare and enforce render stability:
- React's
useState
,useCallback
,useMemo
, etc. returnStable<T>
values - React's dependency arrays must only contain
Stable<T>
values - Our hooks and component props can accept and return
Stable<T>
values
Now Typescript will ensure that we're producing stable values when we expect them and we are feeding them back into hooks and components that expect stable values.
Let's revisit the useQuery
hook from the start and fix it.
This is how hooks are currently typed in React:
export function useQuery0({ onCompleted }: { onCompleted: () => void }): {
foo: () => void;
} {
const foo = useCallback(() => {
onCompleted();
}, [onCompleted]);
return { foo };
}
But we can use our Stable<T>
type to make it clear that it returns an unstable object with a stable foo
field.
export function useQuery1({ onCompleted }: { onCompleted: () => void }): {
foo: Stable<() => void>;
} {
// Type error if we try to return this
// const foo = () => onCompleted()
const foo = useCallback(() => {
onCompleted();
}, [onCompleted]);
return { foo };
}
function Component() {
const object = useQuery1({ onCompleted: () => {} });
const { foo } = object;
useEffect(() => console.log("query changed"), [object]); // Type error
useEffect(() => console.log("foo changed"), [foo]); // Valid
return <div>{foo}</div>;
}
And if we want, we can also declare that useQuery
returns a stable object with a stable foo
field.
export function useQuery2({
onCompleted,
}: {
onCompleted: () => void;
}): Stable<{
foo: Stable<() => void>;
}> {
const foo = useCallback(() => {
onCompleted();
}, [onCompleted]);
// This would fail to typecheck
// return { foo };
// So we can use useMemo to make the return object stable
return useMemo(() => ({ foo }), [foo]);
}
function Component() {
const object = useQuery2({ onCompleted: () => {} });
const { foo } = object;
useEffect(() => console.log("query changed"), [object]); // Valid
useEffect(() => console.log("foo changed"), [foo]); // Valid
return <div>{foo}</div>;
}
Finally, we can use our Stable<T>
type to make it clear that useQuery
expects a stable onCompleted
function.
export function useQuery3({
onCompleted,
}: {
onCompleted: Stable<() => void>;
}): {
foo: Stable<() => void>;
} {
const foo = useCallback(() => {
onCompleted();
}, [onCompleted]);
return { foo };
}
function Component() {
const onCompleted = () => {};
const _ = useQuery3({ onCompleted }); // Type error
const onCompletedStable = useCallback(() => {}, []);
const { foo } = useQuery3({ onCompleted: onCompletedStable }); // Valid
return <div>{foo}</div>;
}
Likewise, we can use our Stable<T>
type to make it clear that component props must be stable.
function Component({ callback }: { callback: Stable<() => void> }) {
useEffect(() => {
callback();
}, [callback]);
return null;
}
function ParentBad() {
const callback = () => {};
return <Component callback={callback} />; // Type error
}
function ParentGood() {
const callbackStable = useCallback(() => {}, []);
return <Component callback={callbackStable} />; // Valid
}
The full impl looks like this:
import React from "react";
export type Stable<T> = T extends
| string
| number
| boolean
| null
| undefined
| symbol
| bigint
? T
: T & { __stable: never };
// Use with caution
export function assertStable<T>(value: T): Stable<T> {
return value as Stable<T>;
}
declare module "react" {
function useState<S>(
initialState: S | (() => S)
): [Stable<S>, Stable<React.Dispatch<React.SetStateAction<S>>>];
function useRef<T>(initialValue: T): Stable<React.RefObject<T>>;
function useMemo<T>(
factory: () => T,
deps: ReadonlyArray<Stable<unknown>>
): Stable<T>;
function useCallback<T extends (...args: unknown[]) => unknown>(
callback: T,
deps: ReadonlyArray<Stable<unknown>>
): Stable<T>;
}