Use Data Query
DraftThe one wrapper over TanStack Query for the apps in this repo: `useDataQuery` reads a value and `useCursorQuery` loads a keyset-paged list one page at a time, so no component imports `@tanstack/react-query` itself.
registry/behivetech/hooks/use-data-query
data-query-provider.tsx
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";2import { useState, type ReactNode } from "react";34/** How long a read counts as fresh when the app does not say. */5const DEFAULT_STALE_MS = 5 * 60 * 1000;67/** Props of `DataQueryProvider`. */8export interface DataQueryProviderProps {9 /** The part of the app that reads through `useDataQuery` and `useCursorQuery`. */10 children?: ReactNode;11 /** How long a read counts as fresh, in milliseconds. Defaults to five minutes. */12 staleMs?: number;13}1415/**16 * Mounts the in-memory cache the hooks of this package read through, so an app17 * never imports `@tanstack/react-query` itself. It adds no element. A read does18 * not retry by itself and does not refetch on window focus: a failed read waits19 * for the reader to ask again. The cache is never persisted; what it holds can20 * be personal, so do not add persistence or devtools in production.21 *22 * @param props The app and the freshness window.23 * @example24 * ```tsx25 * <DataQueryProvider>26 * <App />27 * </DataQueryProvider>28 * ```29 */30export function DataQueryProvider({31 children,32 staleMs = DEFAULT_STALE_MS,33}: DataQueryProviderProps) {34 const [client] = useState(35 () =>36 new QueryClient({37 defaultOptions: {38 queries: {39 retry: false,40 refetchOnWindowFocus: false,41 staleTime: staleMs,42 },43 },44 }),45 );4647 return <QueryClientProvider client={client}>{children}</QueryClientProvider>;48}
use-data-query.ts
1import {2 hashKey,3 useInfiniteQuery,4 useQuery,5 useQueryClient,6 type QueryKey,7} from "@tanstack/react-query";8import { useCallback, useMemo, useState } from "react";910/** How long the later pages of a list stay cached after it unmounts: long enough to come back from an order and find them and short enough that a list left for long starts from a fresh first page. */11const CURSOR_GC_MS = 30_000;1213/** One page of a list read with a keyset cursor. */14export interface CursorPage<TItem> {15 /** The rows of this page, in display order. */16 items: TItem[];17 /** Where the next page starts, or `null` when this is the last page. */18 nextCursor: string | null;19}2021/** Options of `useDataQuery`. */22export interface UseDataQueryOptions<TData> {23 /** Identifies the read in the in-memory cache. Never put a secret in it. */24 queryKey: QueryKey;25 /** Reads the data, usually by calling a server action. */26 queryFn: () => Promise<TData>;27 /** Set to `false` to hold the read until its inputs exist. Defaults to `true`. */28 enabled?: boolean;29 /** Reads again on this schedule while the read is enabled: a delay in milliseconds, `false` for no schedule, or a function given the last data and asked after each read, so the delay can change over time or stop once the data says so. Defaults to `false`. */30 refetchInterval?:31 number | false | ((data: TData | undefined) => number | false);32}3334/** What `useDataQuery` returns. */35export interface UseDataQueryResult<TData> {36 /** The last successful read, if any. */37 data: TData | undefined;38 /** True while the first read has no data yet. */39 isLoading: boolean;40 /** True when the last read failed. */41 isError: boolean;42 /** Reads again. */43 refetch: () => void;44}4546/**47 * Reads one value and caches it in memory for the page's lifetime. It is the48 * only door to `useQuery` for the apps in this repo, so cache settings live in49 * one place and no component imports `@tanstack/react-query` itself.50 *51 * @param options The cache key and the read.52 * @returns The data and its loading and error flags.53 * @example54 * ```tsx55 * const { data, isLoading } = useDataQuery({56 * queryKey: ['refund', orderNumber],57 * queryFn: () => readRefund(orderNumber),58 * });59 * ```60 */61export function useDataQuery<TData>({62 queryKey,63 queryFn,64 enabled = true,65 refetchInterval = false,66}: UseDataQueryOptions<TData>): UseDataQueryResult<TData> {67 const query = useQuery({68 queryKey,69 queryFn,70 enabled,71 refetchInterval:72 typeof refetchInterval === "function"73 ? (current) => refetchInterval(current.state.data)74 : refetchInterval,75 });76 const { refetch } = query;77 const retry = useCallback(() => {78 void refetch();79 }, [refetch]);8081 return {82 data: query.data,83 isLoading: query.isLoading,84 isError: query.isError,85 refetch: retry,86 };87}8889/**90 * Returns a function that drops what the app cached under a key prefix. Reads91 * no component is showing are removed, so the next visit starts from the92 * server's fresh first page. Reads on screen stay (a mounted list keeps the93 * rows it shows) and are marked stale, and `useDataQuery` reads among them are94 * fetched again. Call it after a change the cached data no longer shows, such95 * as an order action, and leave it alone when nothing changed, so coming back96 * keeps the pages already loaded.97 *98 * @returns `invalidate(keyPrefix)`, for example `invalidate(['orders'])`.99 * @example100 * ```tsx101 * const invalidate = useInvalidateData();102 * await refundOrder(number);103 * invalidate(['orders']);104 * ```105 */106export function useInvalidateData(): (keyPrefix: QueryKey) => void {107 const client = useQueryClient();108109 return useCallback(110 (keyPrefix: QueryKey) => {111 client.removeQueries({112 queryKey: keyPrefix,113 predicate: (query) => query.getObserversCount() === 0,114 });115 void client.invalidateQueries({ queryKey: keyPrefix });116 },117 [client],118 );119}120121/** Options of `useCursorQuery`. */122export interface UseCursorQueryOptions<TItem> {123 /** Identifies the list in the in-memory cache; include the first page's `nextCursor` so a changed head is a new list. Never put a secret in it. */124 queryKey: QueryKey;125 /** The first page, from the server render. It is never cached or refetched here, so it is always fresh. */126 initialPage: CursorPage<TItem>;127 /** Reads the page that starts at a cursor, usually by calling a server action. */128 fetchPage: (cursor: string) => Promise<CursorPage<TItem>>;129 /** A stable identity per row. A row seen twice is kept once, as a guard on top of the keyset. */130 getItemKey: (item: TItem) => string | number;131}132133/** What `useCursorQuery` returns. */134export interface UseCursorQueryResult<TItem> {135 /** The first page then every loaded page, without repeated rows. */136 items: TItem[];137 /** True while a page remains to load. */138 hasMore: boolean;139 /** True while a page is on its way. */140 isLoadingMore: boolean;141 /** True when the last page read failed; the rows already loaded stay. */142 isError: boolean;143 /** How many rows the last successful page held, `0` before any load. */144 loadedCount: number;145 /** True once any page after the first was loaded. */146 hasLoadedMore: boolean;147 /** Reads the next page. Does nothing while one is on its way or none remains. */148 loadMore: () => void;149 /** Reads again the page that failed, from the same cursor. */150 retry: () => void;151}152153const dedupe = <TItem>(154 items: TItem[],155 getItemKey: (item: TItem) => string | number,156): TItem[] => {157 const seen = new Set<string | number>();158159 return items.filter((item) => {160 const key = getItemKey(item);161 const isNew = !seen.has(key);162163 seen.add(key);164165 return isNew;166 });167};168169/**170 * Loads a keyset-paged list one page at a time over `useInfiniteQuery`. The171 * first page comes from the caller (the server render) and only the pages after172 * it are cached, so coming back to the list shows the fresh first page and the173 * pages the reader had loaded. A failed read keeps the rows and waits for174 * `retry`; nothing retries by itself.175 *176 * @param options The list's key, first page and page read.177 * @returns The rows so far and the controls to load more.178 * @example179 * ```tsx180 * const { items, hasMore, loadMore } = useCursorQuery({181 * queryKey: ['orders', status, firstPage.nextCursor],182 * initialPage: firstPage,183 * fetchPage: (cursor) => loadMoreOrders({ status, cursor }),184 * getItemKey: (order) => order.number,185 * });186 * ```187 */188export function useCursorQuery<TItem>({189 queryKey,190 initialPage,191 fetchPage,192 getItemKey,193}: UseCursorQueryOptions<TItem>): UseCursorQueryResult<TItem> {194 const client = useQueryClient();195 const keyHash = hashKey(queryKey);196197 // A list that was invalidated (an order changed, or a page read failed, which198 // marks it too) never comes back from the cache: it is dropped when this list199 // mounts or its key changes, never while it shows rows, so a list on screen200 // keeps what it has. Remembering the key in state is the way to react to a201 // changed key while rendering.202 const [seenKey, setSeenKey] = useState<string | null>(null);203204 if (seenKey !== keyHash) {205 const cache = client.getQueryCache();206 const cached = cache.get(keyHash);207208 setSeenKey(keyHash);209210 if (cached?.state.isInvalidated) {211 cache.remove(cached);212 }213 }214215 const query = useInfiniteQuery({216 queryKey,217 initialPageParam: initialPage.nextCursor ?? "",218 queryFn: ({ pageParam }) => fetchPage(pageParam),219 getNextPageParam: (last: CursorPage<TItem>) => last.nextCursor ?? undefined,220 enabled: false,221 gcTime: CURSOR_GC_MS,222 retry: false,223 staleTime: Infinity,224 refetchOnWindowFocus: false,225 });226 const { data, fetchNextPage } = query;227 const loaded = useMemo(() => data?.pages ?? [], [data]);228 const items = useMemo(229 () =>230 dedupe(231 [initialPage.items, ...loaded.map((page) => page.items)].flat(),232 getItemKey,233 ),234 [initialPage.items, loaded, getItemKey],235 );236 const lastPage = loaded.at(-1);237 const hasMore =238 (lastPage ? lastPage.nextCursor : initialPage.nextCursor) !== null;239 const isLoadingMore = query.isFetching;240 const isBusy = isLoadingMore || !hasMore;241 const load = useCallback(() => {242 if (!isBusy) {243 void fetchNextPage();244 }245 }, [fetchNextPage, isBusy]);246247 return {248 items,249 hasMore,250 isLoadingMore,251 isError: query.isError,252 loadedCount: lastPage?.items.length ?? 0,253 hasLoadedMore: loaded.length > 0,254 loadMore: load,255 retry: load,256 };257}
index.ts
1export { DataQueryProvider } from "./data-query-provider.js";2export type { DataQueryProviderProps } from "./data-query-provider.js";3export {4 useCursorQuery,5 useDataQuery,6 useInvalidateData,7} from "./use-data-query.js";8export type {9 CursorPage,10 UseCursorQueryOptions,11 UseCursorQueryResult,12 UseDataQueryOptions,13 UseDataQueryResult,14} from "./use-data-query.js";
use-data-query.example.tsx
1import { DataQueryProvider } from "./data-query-provider.js";2import { useCursorQuery } from "./use-data-query.js";34const fetchPage = async (cursor: string) => {5 const start = Number(cursor);67 return {8 items: [start, start + 1],9 nextCursor: start + 2 > 6 ? null : String(start + 2),10 };11};1213const List = () => {14 const { items, hasMore, isLoadingMore, loadMore } = useCursorQuery({15 queryKey: ["example-list"],16 initialPage: { items: [1, 2], nextCursor: "3" },17 fetchPage,18 getItemKey: (item) => item,19 });2021 return (22 <div>23 <p>{items.join(", ")}</p>24 {hasMore && (25 <button type="button" disabled={isLoadingMore} onClick={loadMore}>26 Load more27 </button>28 )}29 </div>30 );31};3233/** Each exported component here is a live, editable example on the docs site. This comment is its description. */34export const BasicUseDataQuery = () => (35 <DataQueryProvider>36 <List />37 </DataQueryProvider>38);
use-data-query.spec.tsx
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";2import { act, renderHook, waitFor } from "@testing-library/react";3import type { ReactNode } from "react";4import { afterEach, describe, expect, it, vi } from "vitest";5import { DataQueryProvider } from "./data-query-provider.js";6import {7 useCursorQuery,8 useDataQuery,9 useInvalidateData,10 type CursorPage,11} from "./use-data-query.js";1213const wrapper = () => {14 const client = new QueryClient();15 const Wrapper = ({ children }: { children: ReactNode }) => (16 <QueryClientProvider client={client}>{children}</QueryClientProvider>17 );1819 return Wrapper;20};2122const page = (23 items: number[],24 nextCursor: string | null,25): CursorPage<number> => ({ items, nextCursor });2627const options = (28 fetchPage: (cursor: string) => Promise<CursorPage<number>>,29 initialPage = page([1, 2], "2"),30) => ({31 queryKey: ["list", initialPage.nextCursor],32 initialPage,33 fetchPage,34 getItemKey: (item: number) => item,35});3637describe("useDataQuery", () => {38 it("reads a value and reports it", async () => {39 const { result } = renderHook(40 () => useDataQuery({ queryKey: ["a"], queryFn: async () => "hello" }),41 { wrapper: wrapper() },42 );4344 await waitFor(() => expect(result.current.data).toBe("hello"));45 expect(result.current.isError).toBe(false);46 });4748 it("holds the read while it is disabled", () => {49 const queryFn = vi.fn(async () => "x");5051 renderHook(52 () => useDataQuery({ queryKey: ["b"], queryFn, enabled: false }),53 {54 wrapper: wrapper(),55 },56 );5758 expect(queryFn).not.toHaveBeenCalled();59 });6061 it("reads again on the schedule it is given and stops when disabled", async () => {62 vi.useFakeTimers();63 const queryFn = vi.fn(async () => "x");64 const { rerender } = renderHook(65 ({ enabled }) =>66 useDataQuery({67 queryKey: ["poll"],68 queryFn,69 enabled,70 refetchInterval: () => 1000,71 }),72 { wrapper: wrapper(), initialProps: { enabled: true } },73 );7475 await act(() => vi.advanceTimersByTimeAsync(3100));76 const reads = queryFn.mock.calls.length;7778 expect(reads).toBeGreaterThanOrEqual(3);7980 rerender({ enabled: false });81 await act(() => vi.advanceTimersByTimeAsync(5000));8283 expect(queryFn).toHaveBeenCalledTimes(reads);84 vi.useRealTimers();85 });86});8788describe("useCursorQuery", () => {89 it("shows the first page at once and reads nothing until asked", () => {90 const fetchPage = vi.fn(async () => page([3], null));91 const { result } = renderHook(() => useCursorQuery(options(fetchPage)), {92 wrapper: wrapper(),93 });9495 expect(result.current.items).toEqual([1, 2]);96 expect(result.current.hasMore).toBe(true);97 expect(fetchPage).not.toHaveBeenCalled();98 });99100 it("loads the next page from the cursor and appends it", async () => {101 const fetchPage = vi.fn(async () => page([3, 4], null));102 const { result } = renderHook(() => useCursorQuery(options(fetchPage)), {103 wrapper: wrapper(),104 });105106 act(() => result.current.loadMore());107 await waitFor(() => expect(result.current.items).toEqual([1, 2, 3, 4]));108109 expect(fetchPage).toHaveBeenCalledWith("2");110 expect(result.current.hasMore).toBe(false);111 expect(result.current.loadedCount).toBe(2);112 });113114 it("keeps a row once when two pages hold it", async () => {115 const { result } = renderHook(116 () => useCursorQuery(options(async () => page([2, 3], null))),117 { wrapper: wrapper() },118 );119120 act(() => result.current.loadMore());121 await waitFor(() => expect(result.current.items).toEqual([1, 2, 3]));122 });123124 it("makes no further read once the last page is loaded", async () => {125 const fetchPage = vi.fn(async () => page([3], null));126 const { result } = renderHook(() => useCursorQuery(options(fetchPage)), {127 wrapper: wrapper(),128 });129130 act(() => result.current.loadMore());131 await waitFor(() => expect(result.current.hasMore).toBe(false));132 act(() => result.current.loadMore());133134 expect(fetchPage).toHaveBeenCalledTimes(1);135 });136137 it("keeps the loaded rows on a failed page and retries the same cursor", async () => {138 const fetchPage = vi139 .fn<(cursor: string) => Promise<CursorPage<number>>>()140 .mockResolvedValueOnce(page([3], "3"))141 .mockRejectedValueOnce(new Error("down"))142 .mockResolvedValueOnce(page([4], null));143 const { result } = renderHook(() => useCursorQuery(options(fetchPage)), {144 wrapper: wrapper(),145 });146147 act(() => result.current.loadMore());148 await waitFor(() => expect(result.current.items).toEqual([1, 2, 3]));149 act(() => result.current.loadMore());150 await waitFor(() => expect(result.current.isError).toBe(true));151152 expect(result.current.items).toEqual([1, 2, 3]);153154 act(() => result.current.retry());155 await waitFor(() => expect(result.current.items).toEqual([1, 2, 3, 4]));156157 expect(fetchPage).toHaveBeenLastCalledWith("3");158 expect(result.current.isError).toBe(false);159 });160161 it("has nothing to load when the first page is the whole list", () => {162 const fetchPage = vi.fn(async () => page([9], null));163 const { result } = renderHook(164 () => useCursorQuery(options(fetchPage, page([1], null))),165 { wrapper: wrapper() },166 );167168 act(() => result.current.loadMore());169170 expect(result.current.hasMore).toBe(false);171 expect(fetchPage).not.toHaveBeenCalled();172 });173});174175describe("useInvalidateData", () => {176 const setup = () => {177 const client = new QueryClient();178 const Wrapper = ({ children }: { children: ReactNode }) => (179 <QueryClientProvider client={client}>{children}</QueryClientProvider>180 );181182 return { client, Wrapper };183 };184185 it("removes the lists nobody is looking at under the prefix and keeps the rest", async () => {186 const { client, Wrapper } = setup();187 const list = renderHook(188 () => useCursorQuery(options(async () => page([3], null))),189 { wrapper: Wrapper },190 );191192 act(() => list.result.current.loadMore());193 await waitFor(() => expect(list.result.current.items).toEqual([1, 2, 3]));194 list.unmount();195 client.setQueryData(["other"], "kept");196197 const { result } = renderHook(() => useInvalidateData(), {198 wrapper: Wrapper,199 });200201 act(() => result.current(["list"]));202203 expect(client.getQueryData(["list", "2"])).toBeUndefined();204 expect(client.getQueryData(["other"])).toBe("kept");205 });206207 it("keeps the rows of a list that is mounted while it runs", async () => {208 const { client, Wrapper } = setup();209 const { result } = renderHook(210 () => ({211 list: useCursorQuery(options(async () => page([3], null))),212 invalidate: useInvalidateData(),213 }),214 { wrapper: Wrapper },215 );216217 act(() => result.current.list.loadMore());218 await waitFor(() => expect(result.current.list.items).toEqual([1, 2, 3]));219 act(() => result.current.invalidate(["list"]));220221 expect(result.current.list.items).toEqual([1, 2, 3]);222 expect(client.getQueryData(["list", "2"])).toBeDefined();223 });224225 it("keeps the loaded pages when it is not called", async () => {226 const { client, Wrapper } = setup();227 const list = renderHook(228 () => useCursorQuery(options(async () => page([3], null))),229 { wrapper: Wrapper },230 );231232 act(() => list.result.current.loadMore());233 await waitFor(() => expect(list.result.current.items).toEqual([1, 2, 3]));234 list.unmount();235236 expect(client.getQueryData(["list", "2"])).toBeDefined();237 });238});239240describe("DataQueryProvider", () => {241 it("gives its children the hooks without the app importing the query library", async () => {242 const { result } = renderHook(243 () => useDataQuery({ queryKey: ["p"], queryFn: async () => "ok" }),244 { wrapper: DataQueryProvider },245 );246247 await waitFor(() => expect(result.current.data).toBe("ok"));248 });249});250251describe("the cache of a cursor list", () => {252 afterEach(() => {253 vi.useRealTimers();254 });255256 const loadAndUnmount = async (client: QueryClient) => {257 const Wrapper = ({ children }: { children: ReactNode }) => (258 <QueryClientProvider client={client}>{children}</QueryClientProvider>259 );260 const list = renderHook(261 () => ({262 list: useCursorQuery(options(async () => page([3], null))),263 invalidate: useInvalidateData(),264 }),265 { wrapper: Wrapper },266 );267268 act(() => list.result.current.list.loadMore());269 await waitFor(() =>270 expect(list.result.current.list.items).toEqual([1, 2, 3]),271 );272273 return { list, Wrapper };274 };275276 it("starts from the first page after the list was invalidated while mounted", async () => {277 const client = new QueryClient();278 const { list, Wrapper } = await loadAndUnmount(client);279280 act(() => list.result.current.invalidate(["list"]));281 list.unmount();282283 const again = renderHook(284 () => useCursorQuery(options(async () => page([3], null))),285 { wrapper: Wrapper },286 );287288 expect(again.result.current.items).toEqual([1, 2]);289 expect(again.result.current.hasMore).toBe(true);290 });291292 it("keeps the rows of a list that is invalidated while mounted, also on a rerender", async () => {293 const client = new QueryClient();294 const { list } = await loadAndUnmount(client);295296 act(() => list.result.current.invalidate(["list"]));297 await act(async () => {298 await new Promise((resolve) => setTimeout(resolve, 0));299 });300 list.rerender();301302 expect(list.result.current.list.items).toEqual([1, 2, 3]);303 expect(client.getQueryData(["list", "2"])).toBeDefined();304 });305306 it("restores the loaded pages on coming back when nothing was invalidated", async () => {307 const client = new QueryClient();308 const { list, Wrapper } = await loadAndUnmount(client);309310 list.unmount();311312 const again = renderHook(313 () => useCursorQuery(options(async () => page([3], null))),314 { wrapper: Wrapper },315 );316317 expect(again.result.current.items).toEqual([1, 2, 3]);318 });319320 it("keeps the pages for 30 seconds after the list unmounts and no longer", async () => {321 vi.useFakeTimers({ shouldAdvanceTime: true });322323 const client = new QueryClient();324 const { list } = await loadAndUnmount(client);325326 list.unmount();327 vi.advanceTimersByTime(29_000);328329 expect(client.getQueryData(["list", "2"])).toBeDefined();330331 vi.advanceTimersByTime(2000);332333 expect(client.getQueryData(["list", "2"])).toBeUndefined();334 });335});