# 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: ```typescript 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: ```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) 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? 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 <> 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` So, what can we do about these problems? I'm thinking maybe all we need is a `Stable` type that will communicate these expectations. ```typescript type Stable = T & { __stable: never }; ``` - React's `useState`, `useCallback`, `useMemo`, etc. return `Stable` values - React's dependency arrays must only contain `Stable` values - Our hooks and component props can accept and return `Stable` 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` 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; useEffect(() => console.log("query changed"), [object]); // Type error useEffect(() => console.log("foo changed"), [foo]); // Valid return
{foo}
; } ``` 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; useEffect(() => console.log("query changed"), [object]); // Valid useEffect(() => console.log("foo changed"), [foo]); // Valid return
{foo}
; } ``` Finally, we can use our `Stable` 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
{foo}
; } ``` ### Stable component props Likewise, we can use our `Stable` 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 ; // Type error } function ParentGood() { const callbackStable = useCallback(() => {}, []); return ; // Valid } ``` ## Implementation Finally, we want primitive values to satisfy `Stable` where `T` is a primitive. The full impl looks like this: ```typescript import React from "react"; export type Stable = T extends | string | number | boolean | null | undefined | symbol | bigint ? T : T & { __stable: never }; // Use with caution export function assertStable(value: T): Stable { return value as Stable; } declare module "react" { function useState( initialState: S | (() => S) ): [Stable, Stable>>]; function useRef(initialValue: T): Stable>; function useMemo( factory: () => T, deps: ReadonlyArray> ): Stable; function useCallback unknown>( callback: T, deps: ReadonlyArray> ): Stable; } ```