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.

Revisions

  1. danneu revised this gist Apr 22, 2025. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -123,6 +123,8 @@ export type Stable<T> = T extends
    ? 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
  2. danneu revised this gist Apr 22, 2025. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -55,6 +55,8 @@ Not only do you have to answer the first questions, but there are new questions:
    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?

  3. danneu revised this gist Apr 22, 2025. 1 changed file with 15 additions and 2 deletions.
    17 changes: 15 additions & 2 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -106,6 +106,21 @@ I'm thinking maybe all we need is a `Stable<T>` type that will communicate these
    ```typescript
    type Stable<T> = T & { __stable: never };
    ```
    Better yet, primitive values should be stable without having to mark them as stable:
    ```typescript
    export type Stable<T> = T extends
    | string
    | number
    | boolean
    | null
    | undefined
    | symbol
    | bigint
    ? T
    : T & { __stable: never };
    ```
    - React's `useState`, `useCallback`, `useMemo`, etc. return `Stable<T>` values
    - React's dependency arrays must only contain `Stable<T>` values
    @@ -240,8 +255,6 @@ function ParentGood() {
    ## Implementation
    Finally, we want primitive values to satisfy `Stable<T>` where `T` is a primitive.
    The full impl looks like this:
    ```typescript
  4. danneu revised this gist Apr 22, 2025. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -54,9 +54,9 @@ 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 re-render, will `useQuery` also produce a new `foo` or `bar` on every render?
    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?

    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?
    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.

  5. danneu revised this gist Apr 22, 2025. 1 changed file with 11 additions and 11 deletions.
    22 changes: 11 additions & 11 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -11,16 +11,16 @@ function Component {
    const { foo } = useQuery()
    const bar = useQuery()

    useCallback(() => console.log('foo changed'), [foo])
    useCallback(() => console.log('bar changed'), [bar])
    useEffect(() => console.log('foo changed'), [foo])
    useEffect(() => console.log('bar changed'), [bar])

    return null
    }
    ```

    Every time `Component` renders, do those callbacks fire?
    Every time `Component` renders, do those effects fire?

    In other words, what is referentially stable here across React re-renders?
    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!
    @@ -40,8 +40,8 @@ function Component {
    const options = { onCompleted: () => console.log('completed') }
    const bar = useQuery(options)

    useCallback(() => console.log('foo changed'), [foo])
    useCallback(() => console.log('bar changed'), [bar])
    useEffect(() => console.log('foo changed'), [foo])
    useEffect(() => console.log('bar changed'), [bar])

    return null
    }
    @@ -54,7 +54,7 @@ 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 re-render, will `useQuery` also produce a new `foo` or `bar` on every re-render?
    4. Does anything change if you get this wrong? If `onCompleted` changes on every re-render, will `useQuery` also produce a new `foo` or `bar` on every render?

    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?

    @@ -150,8 +150,8 @@ export function useQuery1({ onCompleted }: { onCompleted: () => void }): {
    function Component() {
    const object = useQuery1({ onCompleted: () => {} });
    const { foo } = object;
    useCallback(() => console.log("query changed"), [object]); // Type error
    useCallback(() => console.log("foo changed"), [foo]); // Valid
    useEffect(() => console.log("query changed"), [object]); // Type error
    useEffect(() => console.log("foo changed"), [foo]); // Valid
    return <div>{foo}</div>;
    }
    ```
    @@ -180,8 +180,8 @@ export function useQuery2({
    function Component() {
    const object = useQuery2({ onCompleted: () => {} });
    const { foo } = object;
    useCallback(() => console.log("query changed"), [object]); // Valid
    useCallback(() => console.log("foo changed"), [foo]); // Valid
    useEffect(() => console.log("query changed"), [object]); // Valid
    useEffect(() => console.log("foo changed"), [foo]); // Valid
    return <div>{foo}</div>;
    }
    ```
  6. danneu created this gist Apr 11, 2025.
    283 changes: 283 additions & 0 deletions post.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,283 @@
    # Idea: Fixing a common React bug with a Stable<T> type

    Despite over a decade of using React, there is one bug I still encounter almost daily.

    To demonstrate, here's a question:

    ```typescript
    import { useQuery } from './hooks'

    function Component {
    const { foo } = useQuery()
    const bar = useQuery()

    useCallback(() => console.log('foo changed'), [foo])
    useCallback(() => console.log('bar changed'), [bar])

    return null
    }
    ```

    Every time `Component` renders, do those callbacks fire?

    In other words, what is referentially stable here across React re-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:

    ```typescript
    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)

    useCallback(() => console.log('foo changed'), [foo])
    useCallback(() => 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 re-render, will `useQuery` also produce a new `foo` or `bar` on every re-render?

    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.

    ```typescript
    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:

    ```typescript
    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.
    ```typescript
    type Stable<T> = T & { __stable: never };
    ```
    - 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:
    ```typescript
    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.
    ```typescript
    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;
    useCallback(() => console.log("query changed"), [object]); // Type error
    useCallback(() => 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.
    ```typescript
    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;
    useCallback(() => console.log("query changed"), [object]); // Valid
    useCallback(() => 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.
    ```typescript
    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.
    ```typescript
    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
    Finally, we want primitive values to satisfy `Stable<T>` where `T` is a primitive.
    The full impl looks like this:
    ```typescript
    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>;
    }
    ```