Last active
April 22, 2025 18:03
-
-
Save danneu/4051f5a3ea180aa31358892153beb90b to your computer and use it in GitHub Desktop.
Revisions
-
danneu revised this gist
Apr 22, 2025 . 1 changed file with 2 additions and 0 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 -
danneu revised this gist
Apr 22, 2025 . 1 changed file with 2 additions and 0 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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? -
danneu revised this gist
Apr 22, 2025 . 1 changed file with 15 additions and 2 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 The full impl looks like this: ```typescript -
danneu revised this gist
Apr 22, 2025 . 1 changed file with 2 additions and 2 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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 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? This isn't specific to hooks, either. -
danneu revised this gist
Apr 22, 2025 . 1 changed file with 11 additions and 11 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -11,16 +11,16 @@ 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! @@ -40,8 +40,8 @@ function Component { const options = { onCompleted: () => console.log('completed') } const bar = useQuery(options) 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 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; 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; useEffect(() => console.log("query changed"), [object]); // Valid useEffect(() => console.log("foo changed"), [foo]); // Valid return <div>{foo}</div>; } ``` -
danneu created this gist
Apr 11, 2025 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal 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>; } ```