@raf-query/react 0.0.0-stage → 0.0.1
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 +5 -0
- package/LICENSE.md +20 -0
- package/README.md +455 -2
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +2 -0
- package/package.json +63 -3
package/CHANGELOG.md
ADDED
package/LICENSE.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
'Software'), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,456 @@
|
|
|
1
|
-
#
|
|
1
|
+
# raf-query
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render-as-you-fetch data fetching for React.
|
|
4
|
+
|
|
5
|
+
Queries start in route loaders and React Server Components, travel to components as small serializable **handles**, and are read with React's `use()` under Suspense. Fetch-on-render is an explicitly named escape hatch, not the default.
|
|
6
|
+
|
|
7
|
+
The cache's mechanics (dedupe, staleness, retries, infinite paging, SSR hydration) follow [TanStack Query](https://tanstack.com/query); the public API is built around loaders, RSC, and `use()` instead of `useQuery`-in-render.
|
|
8
|
+
|
|
9
|
+
## Status
|
|
10
|
+
|
|
11
|
+
Design spec. This README describes the API being built; nothing is published yet.
|
|
12
|
+
|
|
13
|
+
## Packages
|
|
14
|
+
|
|
15
|
+
| Package | Contents |
|
|
16
|
+
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `@raf-query/core` | Framework-agnostic: `QueryClient`, cache, `queryOptions` / `infiniteQueryOptions`, handles, dehydrate / hydrate stream, `allKeyed` |
|
|
18
|
+
| `@raf-query/react` | Hooks and provider. Re-exports everything from `@raf-query/core`, so apps install and import only this package |
|
|
19
|
+
|
|
20
|
+
`@raf-query/react` subpath entries for framework integrations:
|
|
21
|
+
|
|
22
|
+
| Entry | Use |
|
|
23
|
+
| ---------------------------------- | ---------------------------------------------------------------------- |
|
|
24
|
+
| `@raf-query/react/tanstack-router` | Streams the router's query cache to the client during SSR |
|
|
25
|
+
| `@raf-query/react/rsc` | `HydrationBoundary` for passing server-component queries to the client |
|
|
26
|
+
| `@raf-query/react/next` | `QueryProvider` for Next.js App Router |
|
|
27
|
+
|
|
28
|
+
## Principles
|
|
29
|
+
|
|
30
|
+
- **Fetch where React recommends.** Loaders and server components start queries. Components read them.
|
|
31
|
+
- **Promises in, `use()` out.** The read hook returns a stable promise; components unwrap it with `use()`, so reads can be conditional or happen in a child.
|
|
32
|
+
- **The URL is the input.** Anything that changes what a query fetches (filters, sort, page) lives in path or search params, so the loader starts the right query on every navigation. Components never derive query input from local state.
|
|
33
|
+
- **Actions for writes.** Mutations are plain async functions in form actions or transitions. The cache provides invalidation, React provides pending and optimistic state.
|
|
34
|
+
|
|
35
|
+
## Defining queries
|
|
36
|
+
|
|
37
|
+
A query is a **factory**: a function of zero or one argument that returns `queryOptions(...)` or `infiniteQueryOptions(...)`. The loader and the component call the same factory, so data types flow end to end.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { infiniteQueryOptions, queryOptions } from "@raf-query/react";
|
|
41
|
+
|
|
42
|
+
export function postQuery(input: { slug: string }) {
|
|
43
|
+
return queryOptions({
|
|
44
|
+
queryKey: ["post", input.slug],
|
|
45
|
+
queryFn: async ({ signal }) => {
|
|
46
|
+
const res = await fetch(`/api/posts/${input.slug}`, { signal });
|
|
47
|
+
return (await res.json()) as Post;
|
|
48
|
+
},
|
|
49
|
+
staleTime: 30_000,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function commentsQuery(input: { slug: string }) {
|
|
54
|
+
return infiniteQueryOptions({
|
|
55
|
+
queryKey: ["comments", input.slug],
|
|
56
|
+
queryFn: ({ pageParam, signal }) =>
|
|
57
|
+
api.comments.list({ slug: input.slug, cursor: pageParam }, { signal }),
|
|
58
|
+
initialPageParam: null as string | null,
|
|
59
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Factories must take at most one argument; the type rejects two or more. The single argument is the query's **input** and must be serializable, because it is what a handle carries.
|
|
65
|
+
|
|
66
|
+
The factory is the only place a query is declared paged. Every other API — `prefetch`, `ensure`, `fetch`, `useQuery`, `useWaterfall`, `invalidate`, `setData` — takes either kind and infers the rest from the factory's type.
|
|
67
|
+
|
|
68
|
+
## Starting queries
|
|
69
|
+
|
|
70
|
+
All three verbs live on the query client and accept a factory plus its input.
|
|
71
|
+
|
|
72
|
+
| Call | Returns | Blocks? | Use for |
|
|
73
|
+
| --------------------------------- | ---------------------- | ------- | ---------------------------------------------------------- |
|
|
74
|
+
| `qc.prefetch(factory, input)` | `QueryHandle` | No | Data the route can render without; it streams in |
|
|
75
|
+
| `await qc.ensure(factory, input)` | `Promise<QueryHandle>` | Yes | Data the route must not render without |
|
|
76
|
+
| `await qc.fetch(factory, input)` | `Promise<Data>` | Yes | Rendering in a server component, or building another input |
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// TanStack Start
|
|
80
|
+
export const Route = createFileRoute("/posts/$slug")({
|
|
81
|
+
loader: async ({ context: { queryClient: qc }, params }) => {
|
|
82
|
+
const comments = qc.prefetch(commentsQuery, { slug: params.slug });
|
|
83
|
+
const post = await qc.ensure(postQuery, { slug: params.slug });
|
|
84
|
+
return { post, comments };
|
|
85
|
+
},
|
|
86
|
+
component: PostPage,
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Await independent blocking queries together with `allKeyed`, which ships with raf-query. It's a ponyfill of the TC39 `Promise.allKeyed` proposal: a record of promises in, a promise of the same record out, rejecting on the first failure.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { allKeyed } from "@raf-query/react";
|
|
94
|
+
|
|
95
|
+
return await allKeyed({
|
|
96
|
+
post: qc.ensure(postQuery, { slug }),
|
|
97
|
+
author: qc.ensure(authorQuery, { slug }),
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Dependent inputs use `fetch`:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const user = await qc.fetch(currentUserQuery);
|
|
105
|
+
return { org: qc.prefetch(orgQuery, { orgId: user.orgId }) };
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Behavior:
|
|
109
|
+
|
|
110
|
+
- Every verb dedupes against the cache. A fresh entry is reused; a stale entry is refetched in the background.
|
|
111
|
+
- `ensure` and `fetch` wait only on a cache miss. With stale cached data they resolve immediately and revalidate in the background.
|
|
112
|
+
- A failed `prefetch` never throws out of the loader. The error surfaces where the data is read, and is reported to the client's `onError`.
|
|
113
|
+
- A failed `ensure` or `fetch` rejects, so it reaches the route's error boundary.
|
|
114
|
+
|
|
115
|
+
## Handles
|
|
116
|
+
|
|
117
|
+
A handle is the only thing that crosses the loader → component or server → client boundary:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
interface QueryHandle<TFactory> {
|
|
121
|
+
readonly key: QueryKey; // ["post", "hello"]
|
|
122
|
+
readonly input: InputOf<TFactory>; // { slug: "hello" }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
## Reading queries
|
|
129
|
+
|
|
130
|
+
### `useQuery`
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const [promise, controls] = useQuery(factory, handle, options?);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`options` takes [`whilePending`](#when-the-url-changes) and per-read refetch settings (`refetchInterval`, `refetchOnWindowFocus`). There is no option to change the input; the handle decides what is read.
|
|
137
|
+
|
|
138
|
+
`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.
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
function PostPage() {
|
|
142
|
+
const loaderData = Route.useLoaderData();
|
|
143
|
+
const [postPromise, post] = useQuery(postQuery, loaderData.post);
|
|
144
|
+
const { title, body } = use(postPromise);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<article>
|
|
148
|
+
<h1>{title}</h1>
|
|
149
|
+
<button
|
|
150
|
+
disabled={post.isFetching}
|
|
151
|
+
onClick={() => startTransition(() => post.refetch())}
|
|
152
|
+
>
|
|
153
|
+
Refresh
|
|
154
|
+
</button>
|
|
155
|
+
<p>{body}</p>
|
|
156
|
+
<Suspense fallback={<CommentsSkeleton />}>
|
|
157
|
+
<Comments handle={loaderData.comments} />
|
|
158
|
+
</Suspense>
|
|
159
|
+
</article>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`controls`:
|
|
165
|
+
|
|
166
|
+
| Field | Meaning |
|
|
167
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
168
|
+
| `isPending` | No data yet for the handle's input; `use(promise)` will suspend |
|
|
169
|
+
| `isPrevious` | The promise holds data for a previous input while the handle's input loads (see [`whilePending`](#when-the-url-changes)) |
|
|
170
|
+
| `isFetching` | A request is in flight: first load, new input, or background refetch |
|
|
171
|
+
| `isStale` | Data is older than `staleTime` |
|
|
172
|
+
| `error` | Error from a failed background refetch. Existing data stays readable |
|
|
173
|
+
| `updatedAt` | When the data in the promise was fetched |
|
|
174
|
+
| `key`, `input` | Key and input of the data in the promise. They differ from the handle's while `isPrevious` is true |
|
|
175
|
+
| `hash` | Stable string form of the handle's key, for `key` props on Suspense boundaries |
|
|
176
|
+
| `refetch()` | Refetch now. Returns a promise, so it can be awaited inside a transition or action |
|
|
177
|
+
|
|
178
|
+
When the cache updates — refetch, invalidation, `setData` — the hook returns a new, pre-resolved promise and the component re-renders without suspending. When the **first** load fails, the promise rejects and `use()` throws to the nearest error boundary. Resetting that boundary refetches the query.
|
|
179
|
+
|
|
180
|
+
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`.
|
|
181
|
+
|
|
182
|
+
### Paged queries
|
|
183
|
+
|
|
184
|
+
With an `infiniteQueryOptions` factory, `useQuery` returns the same tuple. The promise resolves to `{ pages, pageParams }`, and the controls add paging:
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
function Comments(props: { handle: QueryHandle<typeof commentsQuery> }) {
|
|
188
|
+
const [pagesPromise, comments] = useQuery(commentsQuery, props.handle);
|
|
189
|
+
const { pages } = use(pagesPromise);
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<>
|
|
193
|
+
{pages
|
|
194
|
+
.flatMap((page) => page.items)
|
|
195
|
+
.map((comment) => (
|
|
196
|
+
<Comment key={comment.id} {...comment} />
|
|
197
|
+
))}
|
|
198
|
+
<button
|
|
199
|
+
disabled={!comments.hasNextPage || comments.isFetchingNextPage}
|
|
200
|
+
onClick={() => comments.fetchNextPage()}
|
|
201
|
+
>
|
|
202
|
+
Load more
|
|
203
|
+
</button>
|
|
204
|
+
</>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Paging controls: `fetchNextPage()`, `fetchPreviousPage()`, `hasNextPage`, `hasPreviousPage`, `isFetchingNextPage`, `isFetchingPreviousPage`. They exist only on the type for paged factories.
|
|
210
|
+
|
|
211
|
+
On the server, `ensure` and `fetch` load the first page. Pass `{ pages: n }` as a third argument to load more up front; the option is only accepted for paged factories.
|
|
212
|
+
|
|
213
|
+
### When the URL changes
|
|
214
|
+
|
|
215
|
+
Filters, sort order, and pagination are search params. The loader turns them into the query's input, so every navigation starts the right query and the component only ever reads the handle it was given:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
type PostsSearch = { sort: "newest" | "oldest"; page: number };
|
|
219
|
+
|
|
220
|
+
export const Route = createFileRoute("/posts")({
|
|
221
|
+
validateSearch: (search): PostsSearch => ({
|
|
222
|
+
sort: search.sort === "oldest" ? "oldest" : "newest",
|
|
223
|
+
page: Number(search.page ?? 1),
|
|
224
|
+
}),
|
|
225
|
+
loaderDeps: ({ search }) => search,
|
|
226
|
+
loader: ({ context: { queryClient: qc }, deps }) => ({
|
|
227
|
+
posts: qc.prefetch(postsQuery, deps),
|
|
228
|
+
}),
|
|
229
|
+
component: PostsPage,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
function PostsPage() {
|
|
233
|
+
const { posts } = Route.useLoaderData();
|
|
234
|
+
const [postsPromise, postsState] = useQuery(postsQuery, posts, {
|
|
235
|
+
whilePending: "previous",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
<nav aria-busy={postsState.isPrevious}>
|
|
241
|
+
<Link to="." search={(prev) => ({ ...prev, sort: "newest" })}>
|
|
242
|
+
Newest
|
|
243
|
+
</Link>
|
|
244
|
+
<Link to="." search={(prev) => ({ ...prev, sort: "oldest" })}>
|
|
245
|
+
Oldest
|
|
246
|
+
</Link>
|
|
247
|
+
</nav>
|
|
248
|
+
<PostList posts={use(postsPromise)} dimmed={postsState.isPrevious} />
|
|
249
|
+
</>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Clicking a link navigates, the loader prefetches `{ sort: "oldest", page: 1 }` without blocking, and the component receives a new handle. What it renders while that input loads is set by `whilePending`:
|
|
255
|
+
|
|
256
|
+
| `whilePending` | While the new input loads | Use for |
|
|
257
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
|
|
258
|
+
| `"suspend"` (default) | `use()` suspends. TanStack Router and Next.js run navigations as React transitions, so React keeps the whole previous screen, including the old URL's active links, until the data arrives | Short loads, and pages that shouldn't show stale data |
|
|
259
|
+
| `"previous"` | The navigation commits immediately. The promise keeps the previous input's data and `isPrevious` is true until the new data lands | Lists, filters, pagination — anything that should feel instant |
|
|
260
|
+
|
|
261
|
+
Either way, an input that's already cached renders immediately. A component that mounts with a pending handle has no previous data, so `"previous"` suspends like `"suspend"` on first load.
|
|
262
|
+
|
|
263
|
+
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:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
const [postPromise, postState] = useQuery(postQuery, loaderData.post);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<Suspense key={postState.hash} fallback={<PostSkeleton />}>
|
|
270
|
+
<PostBody post={postPromise} />
|
|
271
|
+
</Suspense>
|
|
272
|
+
);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`hash` is the stable string form of the handle's key.
|
|
276
|
+
|
|
277
|
+
In Next.js the same rules apply: read `searchParams` in the page, `prefetch` with them, and pass the handle to the client component.
|
|
278
|
+
|
|
279
|
+
### Passing promises down
|
|
280
|
+
|
|
281
|
+
A component can call the hook and hand the promise to a child, so only the child suspends:
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
const [postPromise] = useQuery(postQuery, loaderData.post);
|
|
285
|
+
return (
|
|
286
|
+
<Suspense fallback={<Spinner />}>
|
|
287
|
+
<PostBody post={postPromise} />
|
|
288
|
+
</Suspense>
|
|
289
|
+
);
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The parent re-renders when `controls` change. If it only passes the promise down, call the hook in the child instead.
|
|
293
|
+
|
|
294
|
+
## Render-time inputs: `useWaterfall`
|
|
295
|
+
|
|
296
|
+
When an input is only known at render time (an id from a parent query's result), no loader can start the query. `useWaterfall` starts it during render and returns a handle, so the consumer looks the same as one fed by a loader:
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
function PostAuthor(props: { post: Post }) {
|
|
300
|
+
const author = useWaterfall(authorQuery, { id: props.post.authorId });
|
|
301
|
+
const [authorPromise] = useQuery(authorQuery, author);
|
|
302
|
+
return <AuthorCard author={use(authorPromise)} />;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The name is the warning: each waterfall is an extra round trip after render. First try to get the input from route params so the loader can start it, or have the backend return it with its parent.
|
|
307
|
+
|
|
308
|
+
For event-driven preloading, call the client directly: `onPointerEnter={() => qc.prefetch(postQuery, { slug })}`.
|
|
309
|
+
|
|
310
|
+
## Mutations
|
|
311
|
+
|
|
312
|
+
There is no `useMutation`. Write data in a form action or transition, then invalidate:
|
|
313
|
+
|
|
314
|
+
```tsx
|
|
315
|
+
function EditTitle(props: { post: Post }) {
|
|
316
|
+
const qc = useQueryClient();
|
|
317
|
+
const [title, setOptimisticTitle] = useOptimistic(props.post.title);
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<form
|
|
321
|
+
action={async (formData) => {
|
|
322
|
+
const next = String(formData.get("title"));
|
|
323
|
+
setOptimisticTitle(next);
|
|
324
|
+
await updatePost({ slug: props.post.slug, title: next });
|
|
325
|
+
await qc.invalidate(postQuery, { slug: props.post.slug });
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<input name="title" defaultValue={title} />
|
|
329
|
+
</form>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`await qc.invalidate(...)` resolves once every query currently being read has refetched, so the action's pending state lasts until the fresh data is on screen.
|
|
335
|
+
|
|
336
|
+
| Call | Effect |
|
|
337
|
+
| ------------------------------------- | ----------------------------------------------------------------------- |
|
|
338
|
+
| `qc.invalidate(factory, input)` | Mark one query stale and refetch it if it is being read |
|
|
339
|
+
| `qc.invalidate({ queryKey, exact? })` | Same, for every query whose key starts with `queryKey` (unless `exact`) |
|
|
340
|
+
| `qc.setData(factory, input, updater)` | Write data directly |
|
|
341
|
+
| `qc.getData(factory, input)` | Read cached data, or `undefined` |
|
|
342
|
+
|
|
343
|
+
## SSR and hydration
|
|
344
|
+
|
|
345
|
+
The server's query client streams its cache to the browser's query client as entries settle. Pending queries cross as promises, so the browser resumes the server's request instead of starting a new one. Components never hydrate anything themselves; a handle only needs its key to find the entry.
|
|
346
|
+
|
|
347
|
+
### TanStack Start
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
// src/router.tsx
|
|
351
|
+
import { QueryClient, QueryClientProvider } from "@raf-query/react";
|
|
352
|
+
import { setupRouterIntegration } from "@raf-query/react/tanstack-router";
|
|
353
|
+
|
|
354
|
+
export function getRouter() {
|
|
355
|
+
const queryClient = new QueryClient();
|
|
356
|
+
const router = createRouter({
|
|
357
|
+
routeTree,
|
|
358
|
+
context: { queryClient },
|
|
359
|
+
Wrap: (props) => (
|
|
360
|
+
<QueryClientProvider client={queryClient}>
|
|
361
|
+
{props.children}
|
|
362
|
+
</QueryClientProvider>
|
|
363
|
+
),
|
|
364
|
+
});
|
|
365
|
+
setupRouterIntegration({ router, queryClient });
|
|
366
|
+
return router;
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`setupRouterIntegration` streams queries during SSR, clears the request's cache when the response finishes, and turns `redirect()` thrown from a `queryFn` into a router navigation. `getRouter` runs per request on the server, so each request gets its own client.
|
|
371
|
+
|
|
372
|
+
### Next.js App Router
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
// app/get-query-client.ts
|
|
376
|
+
import { cache } from "react";
|
|
377
|
+
import { QueryClient } from "@raf-query/react";
|
|
378
|
+
|
|
379
|
+
export const getQueryClient = cache(() => new QueryClient());
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
// app/layout.tsx
|
|
384
|
+
import { QueryProvider } from "@raf-query/react/next";
|
|
385
|
+
|
|
386
|
+
export default function RootLayout(props: { children: React.ReactNode }) {
|
|
387
|
+
return (
|
|
388
|
+
<html lang="en">
|
|
389
|
+
<body>
|
|
390
|
+
<QueryProvider>{props.children}</QueryProvider>
|
|
391
|
+
</body>
|
|
392
|
+
</html>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
// app/posts/[slug]/page.tsx — server component
|
|
399
|
+
import { HydrationBoundary } from "@raf-query/react/rsc";
|
|
400
|
+
|
|
401
|
+
export default async function Page(props: {
|
|
402
|
+
params: Promise<{ slug: string }>;
|
|
403
|
+
}) {
|
|
404
|
+
const { slug } = await props.params;
|
|
405
|
+
const qc = getQueryClient();
|
|
406
|
+
|
|
407
|
+
const comments = qc.prefetch(commentsQuery, { slug });
|
|
408
|
+
const post = await qc.fetch(postQuery, { slug });
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<HydrationBoundary client={qc}>
|
|
412
|
+
<article>
|
|
413
|
+
<h1>{post.title}</h1>
|
|
414
|
+
<Suspense fallback={<CommentsSkeleton />}>
|
|
415
|
+
<Comments handle={comments} />
|
|
416
|
+
</Suspense>
|
|
417
|
+
</article>
|
|
418
|
+
</HydrationBoundary>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- `getQueryClient` is scoped to one request by React's `cache()`.
|
|
424
|
+
- `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.
|
|
425
|
+
- `QueryProvider` creates the browser client, and streams queries that client components start during SSR (`useWaterfall`) into the HTML.
|
|
426
|
+
- Server components that only render data can `await qc.fetch(...)` anywhere; the per-request client dedupes them.
|
|
427
|
+
|
|
428
|
+
#### Server-only queries
|
|
429
|
+
|
|
430
|
+
A factory that imports server-only code (a database client) can't be imported by a client component, so it can't be read with `useQuery`. Render its data in a server component, or pass a plain promise prop and `use()` it on the client. Refresh it with `router.refresh()`.
|
|
431
|
+
|
|
432
|
+
## API reference
|
|
433
|
+
|
|
434
|
+
`@raf-query/core`:
|
|
435
|
+
|
|
436
|
+
| Export | Use |
|
|
437
|
+
| ---------------------------------------------- | ------------------------------------------------- |
|
|
438
|
+
| `QueryClient` | The cache. Methods listed above |
|
|
439
|
+
| `queryOptions(options)` | Typed options for a query factory |
|
|
440
|
+
| `infiniteQueryOptions(options)` | Typed options for a paged query factory |
|
|
441
|
+
| `allKeyed(record)` | Await a record of promises, keeping keys |
|
|
442
|
+
| `dehydrate(client)` / `hydrate(client, state)` | Low-level cache transfer used by the integrations |
|
|
443
|
+
| `QueryHandle<TFactory>` | Handle type for props |
|
|
444
|
+
|
|
445
|
+
`@raf-query/react` (plus all of the above):
|
|
446
|
+
|
|
447
|
+
| Export | Use |
|
|
448
|
+
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
449
|
+
| `QueryClientProvider` | Provides a client to the tree |
|
|
450
|
+
| `useQueryClient()` | Read the client |
|
|
451
|
+
| `useQuery(factory, handle, options?)` | `[Promise<Data>, controls]`; for paged factories `Data` is `{ pages, pageParams }` and controls include paging |
|
|
452
|
+
| `useWaterfall(factory, input)` | Start a query during render and return its handle |
|
|
453
|
+
|
|
454
|
+
## Development
|
|
455
|
+
|
|
456
|
+
See [`.github/DEVELOPMENT.md`](.github/DEVELOPMENT.md).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@raf-query/core";
|
package/dist/index.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raf-query/react",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React bindings for raf-query, a render-as-you-fetch query library",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"query",
|
|
7
|
+
"raf",
|
|
8
|
+
"react",
|
|
9
|
+
"render-as-you-fetch",
|
|
10
|
+
"suspense"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "KATT",
|
|
15
|
+
"email": "alexander@n1s.se"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/KATT/raf-query.git",
|
|
20
|
+
"directory": "packages/react"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/KATT/raf-query#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/KATT/raf-query/issues"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE.md",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./dist/index.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@raf-query/core": "0.0.1",
|
|
39
|
+
"effect": "^4.0.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^19.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@effect/vitest": "4.0.1",
|
|
46
|
+
"@types/node": "26.6.4",
|
|
47
|
+
"@types/react": "19.3.0",
|
|
48
|
+
"@vitest/coverage-v8": "5.0.1",
|
|
49
|
+
"react": "19.3.0",
|
|
50
|
+
"typescript": "7.0.2",
|
|
51
|
+
"vite-plus": "1.0.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public",
|
|
55
|
+
"provenance": true
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "vp pack",
|
|
62
|
+
"test": "vp test",
|
|
63
|
+
"typecheck": "tsc",
|
|
64
|
+
"publish:npm": "pnpm pack --out package.tgz && npm publish package.tgz --access public && rm package.tgz"
|
|
65
|
+
}
|
|
6
66
|
}
|