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.

pnpm add @behivetech/hooks.use-data-query

This is the snapshot of v0.1.2, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/hooks/use-data-query at v0.1.2, snapshotted 2026-11-10

data-query-provider.tsx
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { useState, type ReactNode } from "react";
3
4/** How long a read counts as fresh when the app does not say. */
5const DEFAULT_STALE_MS = 5 * 60 * 1000;
6
7/** 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}
14
15/**
16 * Mounts the in-memory cache the hooks of this package read through, so an app
17 * never imports `@tanstack/react-query` itself. It adds no element. A read does
18 * not retry by itself and does not refetch on window focus: a failed read waits
19 * for the reader to ask again. The cache is never persisted; what it holds can
20 * be personal, so do not add persistence or devtools in production.
21 *
22 * @param props The app and the freshness window.
23 * @example
24 * ```tsx
25 * <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 );
46
47 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";
9
10/** 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;
12
13/** 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}
20
21/** 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}
33
34/** 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}
45
46/**
47 * Reads one value and caches it in memory for the page's lifetime. It is the
48 * only door to `useQuery` for the apps in this repo, so cache settings live in
49 * 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 * @example
54 * ```tsx
55 * 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]);
80
81 return {
82 data: query.data,
83 isLoading: query.isLoading,
84 isError: query.isError,
85 refetch: retry,
86 };
87}
88
89/**
90 * Returns a function that drops what the app cached under a key prefix. Reads
91 * no component is showing are removed, so the next visit starts from the
92 * server's fresh first page. Reads on screen stay (a mounted list keeps the
93 * rows it shows) and are marked stale, and `useDataQuery` reads among them are
94 * fetched again. Call it after a change the cached data no longer shows, such
95 * as an order action, and leave it alone when nothing changed, so coming back
96 * keeps the pages already loaded.
97 *
98 * @returns `invalidate(keyPrefix)`, for example `invalidate(['orders'])`.
99 * @example
100 * ```tsx
101 * const invalidate = useInvalidateData();
102 * await refundOrder(number);
103 * invalidate(['orders']);
104 * ```
105 */
106export function useInvalidateData(): (keyPrefix: QueryKey) => void {
107 const client = useQueryClient();
108
109 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}
120
121/** 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}
132
133/** 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}
152
153const dedupe = <TItem>(
154 items: TItem[],
155 getItemKey: (item: TItem) => string | number,
156): TItem[] => {
157 const seen = new Set<string | number>();
158
159 return items.filter((item) => {
160 const key = getItemKey(item);
161 const isNew = !seen.has(key);
162
163 seen.add(key);
164
165 return isNew;
166 });
167};
168
169/**
170 * Loads a keyset-paged list one page at a time over `useInfiniteQuery`. The
171 * first page comes from the caller (the server render) and only the pages after
172 * it are cached, so coming back to the list shows the fresh first page and the
173 * pages the reader had loaded. A failed read keeps the rows and waits for
174 * `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 * @example
179 * ```tsx
180 * 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);
196
197 // A list that was invalidated (an order changed, or a page read failed, which
198 // marks it too) never comes back from the cache: it is dropped when this list
199 // mounts or its key changes, never while it shows rows, so a list on screen
200 // keeps what it has. Remembering the key in state is the way to react to a
201 // changed key while rendering.
202 const [seenKey, setSeenKey] = useState<string | null>(null);
203
204 if (seenKey !== keyHash) {
205 const cache = client.getQueryCache();
206 const cached = cache.get(keyHash);
207
208 setSeenKey(keyHash);
209
210 if (cached?.state.isInvalidated) {
211 cache.remove(cached);
212 }
213 }
214
215 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]);
246
247 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";
3
4const fetchPage = async (cursor: string) => {
5 const start = Number(cursor);
6
7 return {
8 items: [start, start + 1],
9 nextCursor: start + 2 > 6 ? null : String(start + 2),
10 };
11};
12
13const 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 });
20
21 return (
22 <div>
23 <p>{items.join(", ")}</p>
24 {hasMore && (
25 <button type="button" disabled={isLoadingMore} onClick={loadMore}>
26 Load more
27 </button>
28 )}
29 </div>
30 );
31};
32
33/** 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);