Skip to content

Instantly share code, notes, and snippets.

@wolever
Last active March 31, 2025 18:55
Show Gist options
  • Save wolever/30a34443a9b2982615bbd2e84cd9529e to your computer and use it in GitHub Desktop.
Save wolever/30a34443a9b2982615bbd2e84cd9529e to your computer and use it in GitHub Desktop.
Helper for TanStack's Query cache which will call `Symbol.dispose` on objects as they leave the query cache.
import { QueryClient } from '@tanstack/react-query';
export const SymbolDispose = Symbol('dispose');
type Disposable = { [SymbolDispose]: () => void };
/**
* Subscribe to ``client``'s query cache and dispose of any values that have a
* ``[Symbol.dispose]`` method.
*
* Notes:
* - The exported ``SymbolDispose`` can be used if ``Symbol.dispose`` is not
* available.
* - The ``createDisposingObjectUrl`` helper can be used to create a disposable
* object URL.
*
* For example::
*
* const client = new QueryClient()
* queryClientDisposeOnCacheEvict(client)
*
* export const MyComponent = () => {
* const imageQuery = useQuery({
* queryFn: () => {
* const blob = await fetch(...).then(r => r.blob())
* // Note: see the ``createDisposingObjectUrl`` helper.
* const url = URL.createObjectURL(blob)
* return { url, [Symbol.dispose]: () => URL.revokeObjectURL(url) }
* },
* })
* ...
* }
*/
export const queryClientDisposeOnCacheEvict = (client: QueryClient) => {
const cache = client.getQueryCache();
const needsDispose = new Map<string, Disposable>();
const add = (queryHash: string, newValue: Disposable) => {
const existing = needsDispose.get(queryHash);
if (existing === newValue) {
return;
}
if (existing) {
remove(queryHash);
}
if (newValue?.[SymbolDispose]) {
// console.info('queryClientDisposeOnCacheEvict: tracking', queryHash);
needsDispose.set(queryHash, newValue);
}
};
const remove = (queryHash: string) => {
const oldValue = needsDispose.get(queryHash);
if (oldValue?.[SymbolDispose]) {
// console.info('queryClientDisposeOnCacheEvict: disposing', queryHash);
needsDispose.delete(queryHash);
try {
oldValue[SymbolDispose]();
} catch (err) {
console.error('queryClientDisposeOnCacheEvict: error disposing', queryHash, err);
}
}
};
return cache.subscribe((event) => {
if (event.type == 'added' || event.type == 'updated') {
const value = cache.get(event.query.queryHash)?.state.data;
add(event.query.queryHash, value as any);
}
if (event.type == 'removed') {
remove(event.query.queryHash);
}
});
};
export const createDisposingObjectUrl = (blob: Blob) => {
const url = URL.createObjectURL(blob);
return {
url,
[SymbolDispose]: () => URL.revokeObjectURL(url),
};
};
@joel-s
Copy link

joel-s commented Mar 31, 2025

I adjusted the typing slightly to more accurately reflect what I think our understanding of disposable is: that it may or may not have a SymbolDispose field, but if it does, the field is a function returning void. Does this seem like an improvement?

import { QueryClient } from "@tanstack/react-query";

export const SymbolDispose = Symbol("dispose");
export interface Disposable {
    url: string;
    [SymbolDispose]?: () => void;
}

/**
 * Subscribe to ``client``'s query cache and dispose of any values that have a
 * ``[Symbol.dispose]`` method.
 *
 * Adapted from a Gist in https://github.com/TanStack/query/discussions/1109.
 *
 * Notes:
 * - The exported ``SymbolDispose`` can be used if ``Symbol.dispose`` is not
 *   available.
 * - The ``createDisposingObjectUrl`` helper can be used to create a disposable
 *   object URL.
 *
 * For example::
 *
 *   const client = new QueryClient()
 *   queryClientDisposeOnCacheEvict(client)
 *
 *   export const MyComponent = () => {
 *     const imageQuery = useQuery({
 *       queryFn: () => {
 *         const blob = await fetch(...).then(r => r.blob())
 *         // Note: see the ``createDisposingObjectUrl`` helper.
 *         const url = URL.createObjectURL(blob)
 *         return { url, [Symbol.dispose]: () => URL.revokeObjectURL(url) }
 *       },
 *     })
 *     ...
 *   }
 */
export const queryClientDisposeOnCacheEvict = (client: QueryClient): (() => void) => {
    const cache = client.getQueryCache();
    const needsDispose = new Map<string, Disposable>();

    const add = (queryHash: string, newValue: Disposable): void => {
        const existing = needsDispose.get(queryHash);
        if (existing === newValue) {
            return;
        }

        if (existing) {
            remove(queryHash);
        }

        if (newValue[SymbolDispose]) {
            // console.info('queryClientDisposeOnCacheEvict: tracking', queryHash);
            needsDispose.set(queryHash, newValue);
        }
    };

    const remove = (queryHash: string): void => {
        const oldValue = needsDispose.get(queryHash);
        if (oldValue?.[SymbolDispose]) {
            // console.info('queryClientDisposeOnCacheEvict: disposing', queryHash);
            needsDispose.delete(queryHash);
            try {
                oldValue[SymbolDispose]();
            } catch (err) {
                console.error("queryClientDisposeOnCacheEvict: error disposing", queryHash, err);
            }
        }
    };

    return cache.subscribe((event): void => {
        if (event.type === "added" || event.type === "updated") {
            const value = cache.get(event.query.queryHash)?.state.data;
            add(event.query.queryHash, value as Disposable);
        }

        if (event.type === "removed") {
            remove(event.query.queryHash);
        }
    });
};

export const createDisposingObjectUrl = (blob: Blob): Disposable => {
    const url = URL.createObjectURL(blob);
    return {
        url,
        [SymbolDispose]: () => URL.revokeObjectURL(url),
    };
};

@joel-s
Copy link

joel-s commented Mar 31, 2025

With these changes ESLint complained less about this code, and was able to be helpful in flagging in areas of my code where I used createDisposingObjectUrl but did not correctly destructure the url property. Thank you for this helpful Gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment