Skip to content

Instantly share code, notes, and snippets.

@danneu
Last active April 22, 2025 18:03
Show Gist options
  • Save danneu/4051f5a3ea180aa31358892153beb90b to your computer and use it in GitHub Desktop.
Save danneu/4051f5a3ea180aa31358892153beb90b to your computer and use it in GitHub Desktop.
Idea: Fixing a common React bug with a `Stable<T>` type

Idea: Fixing a common React bug with a Stable type

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?

  1. Does useQuery return an object with stable key foo? You would assume that it would, but it might not!
  2. Does useQuery return a stable object bar? Merely due to React convention, you wouldn't assume so, but it might!
  3. 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:

  1. Does useQuery expect a stable onCompleted function?
  2. Does useQuery let you pass in an unstable onCompleted function as a courtesy but hacks around it internally so that the last value of onCompleted is used when something completes inside it?
  3. Does useQuery expect a stable options object or just stable values?
  4. Does anything change if you get this wrong? If onCompleted changes on every render, will useQuery also produce a new foo or bar 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.

Introducing Stable<T>

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. return Stable<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.

Stable hooks

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>;
}

Stable component props

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
}

Implementation

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>;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment