The 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 at v0.1.1, snapshotted 2026-11-10
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);