@raf-query/react 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.14](https://github.com/KATT/raf-query/compare/0.0.13...0.0.14) (2026-10-12)
4
+
5
+ ### Features
6
+
7
+ * tracked query results via a core QueryObserver, like TanStack Query ([#13](https://github.com/KATT/raf-query/issues/13)) ([7b99b47](https://github.com/KATT/raf-query/commit/7b99b4704be110c999b41277afd5dac4c515f45f))
8
+
9
+ ## [0.0.13](https://github.com/KATT/raf-query/compare/0.0.12...0.0.13) (2026-10-12)
10
+
11
+ ### Features
12
+
13
+ - useQuery returns data for handles from ensure ([#16](https://github.com/KATT/raf-query/issues/16)) ([a50b8fd](https://github.com/KATT/raf-query/commit/a50b8fdc51b952341377d4b55f25ebb6b21d39e1))
14
+
3
15
  ## [0.0.12](https://github.com/KATT/raf-query/compare/0.0.11...0.0.12) (2026-10-12)
4
16
 
5
- * **examples:** move the shared db and ui packages into examples/.shared ([#14](https://github.com/KATT/raf-query/issues/14)) ([fccdc7b](https://github.com/KATT/raf-query/commit/fccdc7bb8075ce2fb0cdbe07b26b36f2ace2bd9c))
17
+ - **examples:** move the shared db and ui packages into examples/.shared ([#14](https://github.com/KATT/raf-query/issues/14)) ([fccdc7b](https://github.com/KATT/raf-query/commit/fccdc7bb8075ce2fb0cdbe07b26b36f2ace2bd9c))
6
18
 
7
19
  ## [0.0.11](https://github.com/KATT/raf-query/compare/0.0.10...0.0.11) (2026-10-12)
8
20
 
package/README.md CHANGED
@@ -72,11 +72,11 @@ The factory is the only place a query is declared paged. Every other API — `pr
72
72
 
73
73
  All three verbs live on the query client and accept a factory plus its input.
74
74
 
75
- | Call | Returns | Blocks? | Use for |
76
- | --------------------------------- | ---------------------- | ------- | ---------------------------------------------------------- |
77
- | `qc.prefetch(factory, input)` | `QueryHandle` | No | Data the route can render without; it streams in |
78
- | `await qc.ensure(factory, input)` | `Promise<QueryHandle>` | Yes | Data the route must not render without |
79
- | `await qc.fetch(factory, input)` | `Promise<Data>` | Yes | Rendering in a server component, or building another input |
75
+ | Call | Returns | Blocks? | Use for |
76
+ | --------------------------------- | ----------------------------- | ------- | ---------------------------------------------------------------------- |
77
+ | `qc.prefetch(factory, input)` | `QueryHandle` | No | Data the route can render without; it streams in |
78
+ | `await qc.ensure(factory, input)` | `Promise<EnsuredQueryHandle>` | Yes | Data the route must not render without; `useQuery` returns it directly |
79
+ | `await qc.fetch(factory, input)` | `Promise<Data>` | Yes | Rendering in a server component, or building another input |
80
80
 
81
81
  ```tsx
82
82
  // TanStack Start
@@ -125,9 +125,16 @@ interface QueryHandle<TFactory> {
125
125
  readonly key: QueryKey; // ["post", "hello"]
126
126
  readonly input: InputOf<TFactory>; // { slug: "hello" }
127
127
  }
128
+
129
+ // From `ensure`: the route waited for the data.
130
+ interface EnsuredQueryHandle<TFactory> {
131
+ readonly key: QueryKey;
132
+ readonly input: InputOf<TFactory>;
133
+ readonly ensured: true;
134
+ }
128
135
  ```
129
136
 
130
- Handles hold no data and no functions. Data reaches the client cache through the [hydration stream](#ssr-and-hydration), so loader data stays tiny and each query is serialized once. The loader's return value lists exactly which queries a route started, and a handle from `ensure` versus `prefetch` shows whether the route waited for it.
137
+ Handles hold no data and no functions. Data reaches the client cache through the [hydration stream](#ssr-and-hydration), so loader data stays tiny and each query is serialized once. The loader's return value lists exactly which queries a route started, and its types show which ones the route waited for: `useQuery` returns the data for an `EnsuredQueryHandle` and a promise for a `QueryHandle`. The two aren't assignable to each other, so type a prop with the one its loader returns.
131
138
 
132
139
  ## Reading queries
133
140
 
@@ -135,17 +142,20 @@ Handles hold no data and no functions. Data reaches the client cache through the
135
142
 
136
143
  ```ts
137
144
  const [promise, controls] = useQuery(factory, handle, options?);
145
+ const [data, controls] = useQuery(factory, ensuredHandle, options?);
138
146
  ```
139
147
 
140
148
  `options` takes [`whilePending`](#when-the-url-changes) and per-read refetch settings (`refetchInterval`, `refetchOnWindowFocus`, `refetchOnReconnect`). There is no option to change the input; the handle decides what is read.
141
149
 
142
150
  `promise` is a stable `Promise<Data>` owned by the cache. Unwrap it with `use()`. When the cache already has data, the promise is pre-resolved and `use()` returns synchronously without suspending.
143
151
 
152
+ A handle from `ensure` had its data loaded before the route rendered, so `useQuery` returns the data itself. If the entry has lost that data since (it was garbage-collected while the router kept the loader data, or `reset`), the hook suspends until it is refetched.
153
+
144
154
  ```tsx
145
155
  function PostPage() {
146
156
  const loaderData = Route.useLoaderData();
147
- const [postPromise, postState] = useQuery(postQuery, loaderData.post);
148
- const post = use(postPromise);
157
+ // `post` came from `qc.ensure`, `comments` from `qc.prefetch`.
158
+ const [post, postState] = useQuery(postQuery, loaderData.post);
149
159
 
150
160
  return (
151
161
  <article>
@@ -179,7 +189,7 @@ function PostPage() {
179
189
  | `hash` | Stable string form of the handle's key, for `key` props on Suspense boundaries |
180
190
  | `refetch()` | Refetch now. Returns a promise, so it can be awaited inside a transition or action |
181
191
 
182
- When the cache updates — refetch, invalidation, `setData` — the hook returns a new, pre-resolved promise and the component re-renders without suspending. New data is structurally shared with the old: objects and arrays that are deeply equal keep their identity, and a refetch that returns equal data keeps the same promise, so nothing re-renders. When the **first** load fails, the promise rejects and `use()` throws to the nearest error boundary. Resetting that boundary refetches the query.
192
+ When the cache updates — refetch, invalidation, `setData` — the hook returns a new, pre-resolved promise and the component re-renders without suspending. New data is structurally shared with the old: objects and arrays that are deeply equal keep their identity, and a refetch that returns equal data keeps the same promise, so nothing re-renders. Controls are tracked: reading `controls.isFetching` subscribes the component to `isFetching`, and a change to a control it never read doesn't re-render it. When the **first** load fails, the promise rejects and `use()` throws to the nearest error boundary. Resetting that boundary refetches the query.
183
193
 
184
194
  Queries do not refetch on mount. They refetch when a loader starts them again, when invalidated, on window focus or reconnect while stale, on `refetch()`, or on `refetchInterval`.
185
195
 
@@ -267,6 +277,7 @@ Either way, an input that's already cached renders immediately. A component that
267
277
  To show a skeleton instead, for instance when moving between unrelated posts, give the Suspense boundary the query's key. React treats it as new content and shows its fallback, even inside a transition:
268
278
 
269
279
  ```tsx
280
+ // This loader prefetches the post instead of ensuring it.
270
281
  const [postPromise, postState] = useQuery(postQuery, loaderData.post);
271
282
 
272
283
  return (
@@ -282,10 +293,10 @@ In Next.js the same rules apply: read `searchParams` in the page, `prefetch` wit
282
293
 
283
294
  ### Passing promises down
284
295
 
285
- A component can call the hook and hand the promise to a child, so only the child suspends:
296
+ A component can call the hook with a prefetched handle and hand the promise to a child, so only the child suspends:
286
297
 
287
298
  ```tsx
288
- const [postPromise] = useQuery(postQuery, loaderData.post);
299
+ const [postPromise] = useQuery(postQuery, loaderData.post); // from `qc.prefetch`
289
300
  return (
290
301
  <Suspense fallback={<Spinner />}>
291
302
  <PostBody post={postPromise} />
@@ -293,7 +304,7 @@ return (
293
304
  );
294
305
  ```
295
306
 
296
- The parent re-renders when `controls` change. If it only passes the promise down, call the hook in the child instead.
307
+ A component re-renders when the promise changes, and when a control it reads changes. Controls it never reads, like `isFetching` here, don't re-render it, so a parent that only passes the promise down stays put during background refetches.
297
308
 
298
309
  ## Render-time inputs: `useWaterfall`
299
310
 
@@ -435,6 +446,7 @@ export default async function Page(props: {
435
446
  - `HydrationBoundary` sends the request client's entries to the browser cache; pending ones stream as promises. Render one in each server component that starts queries for client components. Entries already sent in this request are skipped.
436
447
  - `QueryProvider` creates the browser client, and streams queries that client components start during SSR (`useWaterfall`) into the HTML. Pass `config` for the client's options; it must be serializable.
437
448
  - Server components that only render data can `await qc.fetch(...)` anywhere; the per-request client dedupes them.
449
+ - `fetch` returns data, not a handle. When a server component renders the data and also hands the query to a client component, pass `await qc.ensure(...)`: after the `fetch` it resolves from the cache, and the client component gets the data from `useQuery` without a promise.
438
450
  - Factories can call server actions: server components call them directly, and the browser calls them on refetch. Validate their arguments, since anyone can call them.
439
451
 
440
452
  #### Server-only queries
@@ -545,17 +557,19 @@ Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.de
545
557
 
546
558
  `@raf-query/core`:
547
559
 
548
- | Export | Use |
549
- | ---------------------------------------------- | ---------------------------------------------------------- |
550
- | `QueryClient` | The cache |
551
- | `queryOptions(options)` | Typed options for a query factory |
552
- | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
553
- | `allKeyed(record)` | Await a record of promises, keeping keys |
554
- | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
555
- | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
556
- | `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
557
- | `QueryHandle<TFactory>` | Handle type for props |
558
- | `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
560
+ | Export | Use |
561
+ | ---------------------------------------------- | ---------------------------------------------------------------- |
562
+ | `QueryClient` | The cache |
563
+ | `queryOptions(options)` | Typed options for a query factory |
564
+ | `infiniteQueryOptions(options)` | Typed options for a paged query factory |
565
+ | `allKeyed(record)` | Await a record of promises, keeping keys |
566
+ | `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
567
+ | `hashKey(key)`, `matchesKey(key, prefix)` | Key helpers |
568
+ | `QueryObserver` | One reader's view of a query, with tracked results; for bindings |
569
+ | `CancelledError` | Rejection of a request cancelled by `clear()` or a restart |
570
+ | `QueryHandle<TFactory>` | Handle type for props |
571
+ | `EnsuredQueryHandle<TFactory>` | Handle type for props fed by `ensure` |
572
+ | `DataOf<TFactory>`, `InputOf<TFactory>` | A factory's data and input types |
559
573
 
560
574
  `@raf-query/react` (plus all of the above):
561
575
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { QueryClientProvider, useQueryClient } from "./provider.mjs";
2
- import { PagingControls, QueryControls, UseQueryControls, UseQueryOptions, UseQueryResult, useQuery } from "./use-query.mjs";
2
+ import { PagingControls, QueryControls, UseEnsuredQueryResult, UseQueryControls, UseQueryOptions, UseQueryResult, useQuery } from "./use-query.mjs";
3
3
  import { useWaterfall } from "./use-waterfall.mjs";
4
4
  export * from "@raf-query/core";
5
- export { type PagingControls, QueryClientProvider, type QueryControls, type UseQueryControls, type UseQueryOptions, type UseQueryResult, useQuery, useQueryClient, useWaterfall };
5
+ export { type PagingControls, QueryClientProvider, type QueryControls, type UseEnsuredQueryResult, type UseQueryControls, type UseQueryOptions, type UseQueryResult, useQuery, useQueryClient, useWaterfall };
@@ -1,19 +1,7 @@
1
- import { DataOf, InputOf, IsPaged, QueryClient, QueryFactory, QueryHandle, QueryKey } from "@raf-query/core";
1
+ import { DataOf, EnsuredQueryHandle, InputOf, IsPaged, QueryClient, QueryFactory, QueryHandle, QueryKey, QueryObserverOptions } from "@raf-query/core";
2
2
  //#region src/use-query.d.ts
3
- export interface UseQueryOptions {
4
- /** Refetch on this interval (ms) while mounted and the page is visible. */
5
- readonly refetchInterval?: number;
6
- /** Refetch on reconnect while stale. Default: the query's option, else `true`. */
7
- readonly refetchOnReconnect?: boolean;
8
- /** Refetch on window focus while stale. Default: the query's option, else `true`. */
9
- readonly refetchOnWindowFocus?: boolean;
10
- /**
11
- * What to render while the handle's input has no data yet:
12
- * - `"suspend"` (default): the promise is pending, so `use()` suspends.
13
- * - `"previous"`: keep returning the last input's data, with `isPrevious`.
14
- */
15
- readonly whilePending?: "previous" | "suspend";
16
- }
3
+ /** `refetchInterval`, `refetchOnWindowFocus`, `refetchOnReconnect`, `whilePending`. */
4
+ export type UseQueryOptions = QueryObserverOptions;
17
5
  /** The second element of {@link useQuery}'s result. */
18
6
  export interface QueryControls<TFactory extends QueryFactory> {
19
7
  /** Error of the last failed request. Existing data stays readable. */
@@ -48,6 +36,8 @@ export interface PagingControls {
48
36
  }
49
37
  export type UseQueryControls<TFactory extends QueryFactory> = IsPaged<TFactory> extends true ? QueryControls<TFactory> & PagingControls : QueryControls<TFactory>;
50
38
  export type UseQueryResult<TFactory extends QueryFactory> = readonly [promise: Promise<DataOf<TFactory>>, controls: UseQueryControls<TFactory>];
39
+ /** {@link useQuery}'s result for a handle from `ensure`. */
40
+ export type UseEnsuredQueryResult<TFactory extends QueryFactory> = readonly [data: DataOf<TFactory>, controls: UseQueryControls<TFactory>];
51
41
  /**
52
42
  * Read the query a handle points at. Returns a promise to unwrap with
53
43
  * `use()` and the query's controls:
@@ -59,6 +49,19 @@ export type UseQueryResult<TFactory extends QueryFactory> = readonly [promise: P
59
49
  *
60
50
  * The promise is stable per data version and already fulfilled once the
61
51
  * cache has data, so `use()` only suspends while there is nothing to show.
52
+ * The component re-renders when the promise changes, and when a control it
53
+ * has read changes; changes to controls it never reads are skipped.
54
+ *
55
+ * A handle from `ensure` had its data loaded before the route rendered, so
56
+ * the hook returns the data itself:
57
+ *
58
+ * ```tsx
59
+ * const [post, postState] = useQuery(postQuery, loaderData.post);
60
+ * ```
61
+ *
62
+ * If that entry has lost its data since (garbage-collected, `reset`), the
63
+ * hook suspends until it is refetched.
62
64
  */
65
+ export declare function useQuery<TFactory extends QueryFactory>(factory: TFactory, handle: EnsuredQueryHandle<TFactory>, options?: UseQueryOptions): UseEnsuredQueryResult<TFactory>;
63
66
  export declare function useQuery<TFactory extends QueryFactory>(factory: TFactory, handle: QueryHandle<TFactory>, options?: UseQueryOptions): UseQueryResult<TFactory>;
64
67
  //#endregion
@@ -1,82 +1,42 @@
1
1
  "use client";
2
2
  import { useQueryClient } from "./provider.mjs";
3
3
  import { unsafeCast } from "./cast.mjs";
4
- import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
4
+ import { QueryObserver } from "@raf-query/core";
5
+ import { use, useEffect, useState, useSyncExternalStore } from "react";
5
6
  //#region src/use-query.ts
6
7
  function queryFor(client, factory, input) {
7
8
  const args = [input];
8
9
  return client.getQuery(factory, ...args);
9
10
  }
10
- function useRefetchInterval(query, interval) {
11
- useEffect(() => {
12
- if (interval === void 0 || !Number.isFinite(interval) || interval <= 0) return;
13
- const timer = setInterval(() => {
14
- if (document.visibilityState !== "hidden") query.refetch();
15
- }, interval);
16
- return () => clearInterval(timer);
17
- }, [query, interval]);
18
- }
19
- /**
20
- * Read the query a handle points at. Returns a promise to unwrap with
21
- * `use()` and the query's controls:
22
- *
23
- * ```tsx
24
- * const [postPromise, postState] = useQuery(postQuery, loaderData.post);
25
- * const post = use(postPromise);
26
- * ```
27
- *
28
- * The promise is stable per data version and already fulfilled once the
29
- * cache has data, so `use()` only suspends while there is nothing to show.
30
- */
31
11
  function useQuery(factory, handle, options = {}) {
32
12
  const query = queryFor(useQueryClient(), factory, handle.input);
33
13
  query.read();
14
+ const refetchInterval = options.refetchInterval;
34
15
  const refetchOnReconnect = options.refetchOnReconnect;
35
16
  const refetchOnWindowFocus = options.refetchOnWindowFocus;
36
- const subscribe = useCallback((onChange) => query.subscribe(onChange, {
37
- refetchOnReconnect,
38
- refetchOnWindowFocus
39
- }), [
17
+ const whilePending = options.whilePending;
18
+ const [observer] = useState(() => new QueryObserver(query, options));
19
+ const result = observer.getOptimisticResult(query, options);
20
+ useSyncExternalStore(observer.subscribe, observer.getCurrentResult, observer.getCurrentResult);
21
+ useEffect(() => {
22
+ observer.setOptions(query, {
23
+ refetchInterval,
24
+ refetchOnReconnect,
25
+ refetchOnWindowFocus,
26
+ whilePending
27
+ });
28
+ }, [
29
+ observer,
40
30
  query,
31
+ refetchInterval,
41
32
  refetchOnReconnect,
42
- refetchOnWindowFocus
33
+ refetchOnWindowFocus,
34
+ whilePending
43
35
  ]);
44
- const state = useSyncExternalStore(subscribe, query.getState, query.getState);
45
- useRefetchInterval(query, options.refetchInterval);
46
- const keepPrevious = options.whilePending === "previous";
47
- const [lastShown, setLastShown] = useState(void 0);
48
- if (keepPrevious && state.hasData && lastShown?.promise !== state.promise) setLastShown({
49
- handle: query.handle,
50
- hash: query.hash,
51
- promise: state.promise,
52
- updatedAt: state.updatedAt
53
- });
54
- const previous = keepPrevious && !state.hasData && state.status !== "error" && lastShown !== void 0 && lastShown.hash !== query.hash ? lastShown : void 0;
55
- const shown = previous ?? {
56
- handle: query.handle,
57
- hash: query.hash,
58
- promise: state.promise,
59
- updatedAt: state.updatedAt
60
- };
61
- const controls = {
62
- error: state.error,
63
- fetchNextPage: query.fetchNextPage,
64
- fetchPreviousPage: query.fetchPreviousPage,
65
- hash: query.hash,
66
- hasNextPage: query.hasNextPage(),
67
- hasPreviousPage: query.hasPreviousPage(),
68
- input: shown.handle.input,
69
- isFetching: state.isFetching,
70
- isFetchingNextPage: state.fetchDirection === "forward",
71
- isFetchingPreviousPage: state.fetchDirection === "backward",
72
- isPending: !state.hasData,
73
- isPrevious: previous !== void 0,
74
- isStale: state.isStale,
75
- key: shown.handle.key,
76
- refetch: query.refetch,
77
- updatedAt: shown.updatedAt
78
- };
79
- return unsafeCast([shown.promise, controls]);
36
+ observer.trackProp("promise");
37
+ const controls = observer.trackResult(result);
38
+ if (handle.ensured === true) return unsafeCast([use(result.promise), controls]);
39
+ return unsafeCast([result.promise, controls]);
80
40
  }
81
41
  //#endregion
82
42
  export { queryFor, useQuery };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/react",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "React bindings for raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",
@@ -40,7 +40,7 @@
40
40
  "./tanstack-router": "./dist/tanstack-router.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "@raf-query/core": "0.0.12",
43
+ "@raf-query/core": "0.0.14",
44
44
  "effect": "^4.0.1"
45
45
  },
46
46
  "peerDependencies": {