fetchium 0.1.0 → 0.2.0
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 +12 -5
- package/README.md +1 -1
- package/dist/cjs/development/QueryAdapter-DUo338ga.js +2 -0
- package/dist/cjs/development/QueryAdapter-DUo338ga.js.map +1 -0
- package/dist/cjs/development/QueryClient-m7BzCIe9.js +2 -0
- package/dist/cjs/development/QueryClient-m7BzCIe9.js.map +1 -0
- package/dist/cjs/development/index.js +1 -1
- package/dist/cjs/development/index.js.map +1 -1
- package/dist/cjs/development/mutation-wUhcGxKl.js +2 -0
- package/dist/cjs/development/mutation-wUhcGxKl.js.map +1 -0
- package/dist/cjs/development/react/index.js +1 -1
- package/dist/cjs/development/rest/index.js +2 -0
- package/dist/cjs/development/rest/index.js.map +1 -0
- package/dist/cjs/development/topic/index.js +2 -0
- package/dist/cjs/development/topic/index.js.map +1 -0
- package/dist/cjs/production/QueryAdapter-DUo338ga.js +2 -0
- package/dist/cjs/production/QueryAdapter-DUo338ga.js.map +1 -0
- package/dist/cjs/production/QueryClient-4T90peFN.js +2 -0
- package/dist/cjs/production/QueryClient-4T90peFN.js.map +1 -0
- package/dist/cjs/production/index.js +1 -1
- package/dist/cjs/production/index.js.map +1 -1
- package/dist/cjs/production/mutation-Dk0gznwX.js +2 -0
- package/dist/cjs/production/mutation-Dk0gznwX.js.map +1 -0
- package/dist/cjs/production/react/index.js +1 -1
- package/dist/cjs/production/rest/index.js +2 -0
- package/dist/cjs/production/rest/index.js.map +1 -0
- package/dist/cjs/production/topic/index.js +2 -0
- package/dist/cjs/production/topic/index.js.map +1 -0
- package/dist/esm/MutationResult.d.ts +0 -1
- package/dist/esm/MutationResult.d.ts.map +1 -1
- package/dist/esm/QueryAdapter.d.ts +49 -0
- package/dist/esm/QueryAdapter.d.ts.map +1 -0
- package/dist/esm/QueryClient.d.ts +26 -4
- package/dist/esm/QueryClient.d.ts.map +1 -1
- package/dist/esm/QueryResult.d.ts +11 -11
- package/dist/esm/QueryResult.d.ts.map +1 -1
- package/dist/esm/development/QueryAdapter-Bu5UJjE4.js +14 -0
- package/dist/esm/development/QueryAdapter-Bu5UJjE4.js.map +1 -0
- package/dist/esm/development/QueryClient-BajBmpnA.js +2572 -0
- package/dist/esm/development/QueryClient-BajBmpnA.js.map +1 -0
- package/dist/esm/development/index.js +29 -100
- package/dist/esm/development/index.js.map +1 -1
- package/dist/esm/development/mutation-DAOZE4Ok.js +58 -0
- package/dist/esm/development/mutation-DAOZE4Ok.js.map +1 -0
- package/dist/esm/development/react/index.js +1 -1
- package/dist/esm/development/rest/index.js +142 -0
- package/dist/esm/development/rest/index.js.map +1 -0
- package/dist/esm/development/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
- package/dist/esm/development/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
- package/dist/esm/development/stores/async.js +6 -6
- package/dist/esm/development/stores/sync.js +5 -5
- package/dist/esm/development/topic/index.js +86 -0
- package/dist/esm/development/topic/index.js.map +1 -0
- package/dist/esm/index.d.ts +5 -4
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/mutation.d.ts +6 -19
- package/dist/esm/mutation.d.ts.map +1 -1
- package/dist/esm/production/QueryAdapter-Bu5UJjE4.js +14 -0
- package/dist/esm/production/QueryAdapter-Bu5UJjE4.js.map +1 -0
- package/dist/esm/production/{QueryClient-BP0Z1rQV.js → QueryClient-KH0Ex_8m.js} +595 -591
- package/dist/esm/production/QueryClient-KH0Ex_8m.js.map +1 -0
- package/dist/esm/production/index.js +29 -100
- package/dist/esm/production/index.js.map +1 -1
- package/dist/esm/production/mutation-C7BOChR2.js +58 -0
- package/dist/esm/production/mutation-C7BOChR2.js.map +1 -0
- package/dist/esm/production/react/index.js +1 -1
- package/dist/esm/production/rest/index.js +142 -0
- package/dist/esm/production/rest/index.js.map +1 -0
- package/dist/esm/production/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
- package/dist/esm/production/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
- package/dist/esm/production/stores/async.js +6 -6
- package/dist/esm/production/stores/sync.js +5 -5
- package/dist/esm/production/topic/index.js +86 -0
- package/dist/esm/production/topic/index.js.map +1 -0
- package/dist/esm/query-types.d.ts +2 -4
- package/dist/esm/query-types.d.ts.map +1 -1
- package/dist/esm/query.d.ts +17 -39
- package/dist/esm/query.d.ts.map +1 -1
- package/dist/esm/rest/RESTMutation.d.ts +18 -0
- package/dist/esm/rest/RESTMutation.d.ts.map +1 -0
- package/dist/esm/rest/RESTQuery.d.ts +24 -0
- package/dist/esm/rest/RESTQuery.d.ts.map +1 -0
- package/dist/esm/rest/RESTQueryAdapter.d.ts +34 -0
- package/dist/esm/rest/RESTQueryAdapter.d.ts.map +1 -0
- package/dist/esm/rest/index.d.ts +5 -0
- package/dist/esm/rest/index.d.ts.map +1 -0
- package/dist/esm/stores/shared.d.ts.map +1 -1
- package/dist/esm/testing/MockClient.d.ts +64 -0
- package/dist/esm/testing/MockClient.d.ts.map +1 -0
- package/dist/esm/testing/auto-generate.d.ts +20 -0
- package/dist/esm/testing/auto-generate.d.ts.map +1 -0
- package/dist/esm/testing/entity-factory.d.ts +13 -0
- package/dist/esm/testing/entity-factory.d.ts.map +1 -0
- package/dist/esm/testing/index.d.ts +6 -0
- package/dist/esm/testing/index.d.ts.map +1 -0
- package/dist/esm/testing/types.d.ts +37 -0
- package/dist/esm/testing/types.d.ts.map +1 -0
- package/dist/esm/topic/TopicQuery.d.ts +10 -0
- package/dist/esm/topic/TopicQuery.d.ts.map +1 -0
- package/dist/esm/topic/TopicQueryAdapter.d.ts +43 -0
- package/dist/esm/topic/TopicQueryAdapter.d.ts.map +1 -0
- package/dist/esm/topic/index.d.ts +3 -0
- package/dist/esm/topic/index.d.ts.map +1 -0
- package/dist/esm/typeDefs.d.ts +1 -1
- package/dist/esm/types.d.ts +9 -4
- package/dist/esm/types.d.ts.map +1 -1
- package/package.json +51 -4
- package/plugin/.claude-plugin/plugin.json +10 -0
- package/plugin/agents/fetchium.md +168 -0
- package/plugin/docs/api/fetchium-react.md +135 -0
- package/plugin/docs/api/fetchium.md +674 -0
- package/plugin/docs/api/stores-async.md +219 -0
- package/plugin/docs/api/stores-sync.md +133 -0
- package/plugin/docs/core/entities.md +351 -0
- package/plugin/docs/core/queries.md +600 -0
- package/plugin/docs/core/streaming.md +550 -0
- package/plugin/docs/core/types.md +374 -0
- package/plugin/docs/data/caching.md +298 -0
- package/plugin/docs/data/live-data.md +435 -0
- package/plugin/docs/data/mutations.md +465 -0
- package/plugin/docs/guides/auth.md +318 -0
- package/plugin/docs/guides/error-handling.md +351 -0
- package/plugin/docs/guides/offline.md +270 -0
- package/plugin/docs/guides/testing.md +301 -0
- package/plugin/docs/quickstart.md +170 -0
- package/plugin/docs/reference/pagination.md +519 -0
- package/plugin/docs/reference/rest-queries.md +107 -0
- package/plugin/docs/reference/why-signalium.md +364 -0
- package/plugin/docs/setup/project-setup.md +319 -0
- package/plugin/install.mjs +88 -0
- package/plugin/skills/design/SKILL.md +140 -0
- package/plugin/skills/teach/SKILL.md +105 -0
- package/dist/cjs/development/QueryClient-CpmwggOn.js +0 -2
- package/dist/cjs/development/QueryClient-CpmwggOn.js.map +0 -1
- package/dist/cjs/production/QueryClient-qi3bR0eD.js +0 -2
- package/dist/cjs/production/QueryClient-qi3bR0eD.js.map +0 -1
- package/dist/esm/development/QueryClient-DRZtPKFD.js +0 -2568
- package/dist/esm/development/QueryClient-DRZtPKFD.js.map +0 -1
- package/dist/esm/production/QueryClient-BP0Z1rQV.js.map +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Getting started
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Fetchium is a reactive data-fetching library built on [Signalium](/reference/why-signalium). It gives you class-based query definitions, automatic entity normalization and caching, a type DSL for describing API shapes, and first-class React integration --- all driven by Signalium's fine-grained reactivity engine.
|
|
6
|
+
|
|
7
|
+
With Fetchium you define your API surface as plain classes. The library handles fetch deduplication, caching, staleness, background refetching, offline support, and entity identity so your components stay simple and your data stays consistent.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Quick Start Guide {% #getting-started %}
|
|
12
|
+
|
|
13
|
+
### 1. Install the packages
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Using npm
|
|
17
|
+
npm install fetchium signalium
|
|
18
|
+
|
|
19
|
+
# Using yarn
|
|
20
|
+
yarn add fetchium signalium
|
|
21
|
+
|
|
22
|
+
# Using pnpm
|
|
23
|
+
pnpm add fetchium signalium
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 2. Setup the Babel transform
|
|
27
|
+
|
|
28
|
+
Signalium requires a Babel transform to enable async reactivity. Add it to your bundler config so that async dependency tracking works correctly.
|
|
29
|
+
|
|
30
|
+
#### Vite + React
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
import { defineConfig } from 'vite';
|
|
34
|
+
import react from '@vitejs/plugin-react';
|
|
35
|
+
import { signaliumPreset } from 'signalium/transform';
|
|
36
|
+
|
|
37
|
+
export default defineConfig({
|
|
38
|
+
plugins: [
|
|
39
|
+
react({
|
|
40
|
+
babel: {
|
|
41
|
+
presets: [signaliumPreset()],
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### babel.config.js
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { signaliumPreset } from 'signalium/transform';
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
presets: [
|
|
55
|
+
'@babel/preset-env',
|
|
56
|
+
'@babel/preset-react',
|
|
57
|
+
'@babel/preset-typescript',
|
|
58
|
+
signaliumPreset(),
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 3. Create a QueryClient and wrap your app
|
|
64
|
+
|
|
65
|
+
Every Fetchium app needs a `QueryClient` backed by a store. The client manages query instances, the entity cache, and network state. Wrap your component tree in a `ContextProvider` so that queries can find the client.
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { QueryClient, QueryClientContext } from 'fetchium';
|
|
69
|
+
import { ContextProvider } from 'signalium/react';
|
|
70
|
+
|
|
71
|
+
const client = new QueryClient();
|
|
72
|
+
|
|
73
|
+
function App() {
|
|
74
|
+
return (
|
|
75
|
+
<ContextProvider value={client} context={QueryClientContext}>
|
|
76
|
+
<YourApp />
|
|
77
|
+
</ContextProvider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is the minimal setup. The store defaults to an in-memory cache and `RESTQueryAdapter` is auto-instantiated on first use with `globalThis.fetch`. When you need a `baseUrl`, auth headers, or persistent storage, pass explicit options --- see [Project Setup](/setup/project-setup).
|
|
83
|
+
|
|
84
|
+
{% callout title="Want to go deeper?" type="note" %}
|
|
85
|
+
For a complete guide to configuring `baseUrl`, auth headers, persistent stores, and project structure, see [Project Setup](/setup/project-setup).
|
|
86
|
+
{% /callout %}
|
|
87
|
+
|
|
88
|
+
### 4. Define an Entity and a Query
|
|
89
|
+
|
|
90
|
+
Entities describe the shape of your API resources. Queries describe how to fetch them. Both use the `t` type DSL for field definitions.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import { t, Entity } from 'fetchium';
|
|
94
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
95
|
+
|
|
96
|
+
class User extends Entity {
|
|
97
|
+
__typename = t.typename('User');
|
|
98
|
+
id = t.id;
|
|
99
|
+
|
|
100
|
+
name = t.string;
|
|
101
|
+
email = t.string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class GetUser extends RESTQuery {
|
|
105
|
+
params = { id: t.number };
|
|
106
|
+
|
|
107
|
+
path = `/users/${this.params.id}`;
|
|
108
|
+
|
|
109
|
+
result = { user: t.entity(User) };
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`t.typename` and `t.id` identify the entity for normalization and deduplication. `path` uses template literal interpolation with `this.params` to embed parameter values. `t.entity(User)` tells Fetchium to parse and normalize the response as a `User` entity.
|
|
114
|
+
|
|
115
|
+
### 5. Use the query in a component
|
|
116
|
+
|
|
117
|
+
```tsx {% mode="react" %}
|
|
118
|
+
import { useQuery } from 'fetchium/react';
|
|
119
|
+
|
|
120
|
+
function UserProfile({ userId }: { userId: number }) {
|
|
121
|
+
const result = useQuery(GetUser, { id: userId });
|
|
122
|
+
|
|
123
|
+
if (!result.isReady) return <div>Loading...</div>;
|
|
124
|
+
if (result.isRejected) return <div>Error: {result.error.message}</div>;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div>
|
|
128
|
+
<h1>{result.value.user.name}</h1>
|
|
129
|
+
<p>{result.value.user.email}</p>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```tsx {% mode="signalium" %}
|
|
136
|
+
import { fetchQuery } from 'fetchium';
|
|
137
|
+
import { component } from 'signalium/react';
|
|
138
|
+
|
|
139
|
+
const UserProfile = component(({ userId }: { userId: number }) => {
|
|
140
|
+
const result = fetchQuery(GetUser, { id: userId });
|
|
141
|
+
|
|
142
|
+
if (!result.isReady) return <div>Loading...</div>;
|
|
143
|
+
if (result.isRejected) return <div>Error: {result.error.message}</div>;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div>
|
|
147
|
+
<h1>{result.value.user.name}</h1>
|
|
148
|
+
<p>{result.value.user.email}</p>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Both approaches return a `ReactivePromise` with properties like `value`, `error`, `isPending`, `isReady`, `isResolved`, and `isRejected`. The component re-renders automatically when the query state changes.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Next Steps
|
|
159
|
+
|
|
160
|
+
{% quick-links %}
|
|
161
|
+
|
|
162
|
+
{% quick-link title="Project Setup" icon="installation" href="/setup/project-setup" description="Configure baseUrl, auth, stores, and project structure for production" /%}
|
|
163
|
+
|
|
164
|
+
{% quick-link title="Queries" icon="presets" href="/core/queries" description="Deep dive into query definitions, the template system, and usage patterns" /%}
|
|
165
|
+
|
|
166
|
+
{% quick-link title="Entities" icon="plugins" href="/core/entities" description="Understand normalized entity caching and identity-stable proxies" /%}
|
|
167
|
+
|
|
168
|
+
{% quick-link title="Auth & Headers" icon="theming" href="/guides/auth" description="Add authentication tokens and custom headers to your requests" /%}
|
|
169
|
+
|
|
170
|
+
{% /quick-links %}
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Pagination & Infinite Queries
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Fetchium supports cursor-based, offset-based, and URL-based pagination through the `fetchNext` configuration on queries. When `fetchNext` is configured, the query result exposes `__fetchNext()`, `__hasNext`, and `__isFetchingNext` --- giving you everything you need to build infinite scroll, "load more" buttons, and paginated lists.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How Pagination Works
|
|
10
|
+
|
|
11
|
+
Pagination in Fetchium is declarative. You define how to fetch the next page on your query class, and Fetchium handles the rest: resolving cursor values from the current response, executing the next-page request, and merging results.
|
|
12
|
+
|
|
13
|
+
The flow is:
|
|
14
|
+
|
|
15
|
+
1. Initial query fetches the first page.
|
|
16
|
+
2. The response includes pagination metadata (a cursor, an offset, or a next URL).
|
|
17
|
+
3. `__fetchNext()` reads the pagination metadata from the current result, constructs the next request, and fetches it.
|
|
18
|
+
4. Results are merged --- live arrays **append** new entities, while plain arrays are **replaced**.
|
|
19
|
+
5. The pagination metadata is updated to reflect the new response, so the next `__fetchNext()` call fetches the correct page.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Configuring Pagination
|
|
24
|
+
|
|
25
|
+
Add a `fetchNext` field to your `RESTQuery` class. It accepts two optional properties:
|
|
26
|
+
|
|
27
|
+
| Property | Type | Description |
|
|
28
|
+
| -------------- | ------------------------- | ----------------------------------------------------- |
|
|
29
|
+
| `url` | `string` or FieldRef | Override the URL for the next page request |
|
|
30
|
+
| `searchParams` | `Record<string, unknown>` | Search parameters to append. Values can be FieldRefs. |
|
|
31
|
+
|
|
32
|
+
### Cursor-based pagination
|
|
33
|
+
|
|
34
|
+
The most common pattern. Your API returns a cursor in the response body, and you pass it as a search param on the next request.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { t, Entity } from 'fetchium';
|
|
38
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
39
|
+
|
|
40
|
+
class Item extends Entity {
|
|
41
|
+
__typename = t.typename('Item');
|
|
42
|
+
id = t.id;
|
|
43
|
+
|
|
44
|
+
name = t.string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class GetItems extends RESTQuery {
|
|
48
|
+
path = '/items';
|
|
49
|
+
|
|
50
|
+
result = {
|
|
51
|
+
items: t.liveArray(Item),
|
|
52
|
+
nextCursor: t.optional(t.string),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
fetchNext = {
|
|
56
|
+
searchParams: {
|
|
57
|
+
cursor: this.result.nextCursor,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The value `this.result.nextCursor` is a **field reference** (FieldRef). At runtime, when `__fetchNext()` is called, Fetchium resolves the FieldRef against the current result data. If the first response returned `{ nextCursor: 'abc123' }`, the next request will include `?cursor=abc123`.
|
|
64
|
+
|
|
65
|
+
### Offset-based pagination
|
|
66
|
+
|
|
67
|
+
For APIs that use page numbers or offsets:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
class GetItems extends RESTQuery {
|
|
71
|
+
path = '/items';
|
|
72
|
+
|
|
73
|
+
result = {
|
|
74
|
+
items: t.array(t.string),
|
|
75
|
+
nextPage: t.optional(t.number),
|
|
76
|
+
limit: t.number,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
fetchNext = {
|
|
80
|
+
searchParams: {
|
|
81
|
+
page: this.result.nextPage,
|
|
82
|
+
limit: this.result.limit,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Multiple FieldRefs can be used in the same `searchParams` object. Each is resolved independently against the current result data.
|
|
89
|
+
|
|
90
|
+
### URL-based pagination
|
|
91
|
+
|
|
92
|
+
Some APIs return a full URL for the next page. Use the `url` property instead of `searchParams`:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
class GetItems extends RESTQuery {
|
|
96
|
+
path = '/items';
|
|
97
|
+
|
|
98
|
+
result = {
|
|
99
|
+
items: t.array(t.string),
|
|
100
|
+
nextUrl: t.optional(t.string),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
fetchNext = {
|
|
104
|
+
url: this.result.nextUrl,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
When `__fetchNext()` is called, Fetchium fetches the resolved URL directly instead of constructing one from the original path and search params.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Dynamic Pagination with `getFetchNext()`
|
|
114
|
+
|
|
115
|
+
For cases where the pagination logic depends on runtime conditions --- response headers, error codes, or computed values --- override `getFetchNext()` instead of using the static `fetchNext` field.
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
class GetItems extends RESTQuery {
|
|
119
|
+
path = '/items';
|
|
120
|
+
|
|
121
|
+
result = { items: t.array(t.string), total: t.number };
|
|
122
|
+
|
|
123
|
+
getFetchNext() {
|
|
124
|
+
// Use a page token from response headers if available
|
|
125
|
+
const pageToken = this.response?.headers?.get?.('X-Next-Page-Token');
|
|
126
|
+
|
|
127
|
+
if (pageToken) {
|
|
128
|
+
return { searchParams: { pageToken } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fall back to offset-based pagination
|
|
132
|
+
return { searchParams: { offset: 1 } };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`getFetchNext()` has access to `this.response` (the raw `Response` object from the previous fetch) and `this.params` (the query params). It should return a `FetchNextConfig` object or `undefined`.
|
|
138
|
+
|
|
139
|
+
### Return `undefined` to disable pagination
|
|
140
|
+
|
|
141
|
+
If `getFetchNext()` returns `undefined`, `__hasNext` will be `false` and `__fetchNext()` will throw. Use this to conditionally disable pagination:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
class GetItems extends RESTQuery {
|
|
145
|
+
path = '/items';
|
|
146
|
+
|
|
147
|
+
result = {
|
|
148
|
+
items: t.array(t.string),
|
|
149
|
+
hasMore: t.boolean,
|
|
150
|
+
nextPage: t.optional(t.number),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
getFetchNext() {
|
|
154
|
+
// Only allow pagination on successful responses
|
|
155
|
+
if (this.response?.status !== 200) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return { searchParams: { page: 2 } };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Priority: `getFetchNext()` overrides `fetchNext`
|
|
164
|
+
|
|
165
|
+
When both a static `fetchNext` field and a `getFetchNext()` method are defined, the method takes priority. The static field is ignored.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Using Pagination in Components
|
|
170
|
+
|
|
171
|
+
```tsx {% mode="react" %}
|
|
172
|
+
import { useQuery } from 'fetchium/react';
|
|
173
|
+
|
|
174
|
+
function ItemList() {
|
|
175
|
+
const query = useQuery(GetItems);
|
|
176
|
+
|
|
177
|
+
if (query.isPending) return <div>Loading...</div>;
|
|
178
|
+
|
|
179
|
+
const result = query.value;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
<ul>
|
|
184
|
+
{result.items.map((item) => (
|
|
185
|
+
<li key={item.id}>{item.name}</li>
|
|
186
|
+
))}
|
|
187
|
+
</ul>
|
|
188
|
+
|
|
189
|
+
{result.__hasNext && (
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => result.__fetchNext()}
|
|
192
|
+
disabled={result.__isFetchingNext}
|
|
193
|
+
>
|
|
194
|
+
{result.__isFetchingNext ? 'Loading...' : 'Load More'}
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```tsx {% mode="signalium" %}
|
|
203
|
+
import { fetchQuery } from 'fetchium';
|
|
204
|
+
import { component } from 'signalium/react';
|
|
205
|
+
|
|
206
|
+
const ItemList = component(() => {
|
|
207
|
+
const query = fetchQuery(GetItems);
|
|
208
|
+
|
|
209
|
+
if (query.isPending) return <div>Loading...</div>;
|
|
210
|
+
|
|
211
|
+
const result = query.value;
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div>
|
|
215
|
+
<ul>
|
|
216
|
+
{result.items.map((item) => (
|
|
217
|
+
<li key={item.id}>{item.name}</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
|
|
221
|
+
{result.__hasNext && (
|
|
222
|
+
<button
|
|
223
|
+
onClick={() => result.__fetchNext()}
|
|
224
|
+
disabled={result.__isFetchingNext}
|
|
225
|
+
>
|
|
226
|
+
{result.__isFetchingNext ? 'Loading...' : 'Load More'}
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Headless usage (outside React)
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { fetchQuery } from 'fetchium';
|
|
238
|
+
|
|
239
|
+
const relay = fetchQuery(GetItems);
|
|
240
|
+
await relay;
|
|
241
|
+
|
|
242
|
+
console.log(relay.value.items); // First page items
|
|
243
|
+
|
|
244
|
+
if (relay.value.__hasNext) {
|
|
245
|
+
await relay.value.__fetchNext();
|
|
246
|
+
console.log(relay.value.items); // Updated items (appended for live arrays)
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## QueryResult Pagination Properties
|
|
253
|
+
|
|
254
|
+
When `fetchNext` is configured on a query, the query result object gains three additional properties:
|
|
255
|
+
|
|
256
|
+
| Property | Type | Description |
|
|
257
|
+
| ------------------ | --------------- | ------------------------------------------------------------------------------- |
|
|
258
|
+
| `__fetchNext()` | `() => Promise` | Fetches the next page. Returns a promise that resolves when the page is loaded. |
|
|
259
|
+
| `__hasNext` | `boolean` | Whether more pages are available. |
|
|
260
|
+
| `__isFetchingNext` | `boolean` | Whether a next-page request is currently in flight. |
|
|
261
|
+
|
|
262
|
+
All three properties are **reactive** --- reading them inside a Signalium reactive function or a `component()` establishes a dependency, so your UI updates automatically when the values change.
|
|
263
|
+
|
|
264
|
+
### How `__hasNext` is determined
|
|
265
|
+
|
|
266
|
+
For **static `fetchNext`** (with FieldRefs), `__hasNext` is `true` when all FieldRef values in the `searchParams` (or `url`) resolve to non-null, non-undefined values. When the API returns `nextCursor: undefined` or `nextCursor: null`, `__hasNext` becomes `false`.
|
|
267
|
+
|
|
268
|
+
For **`getFetchNext()`**, `__hasNext` is `true` when the method returns a non-undefined config object, and `false` when it returns `undefined`.
|
|
269
|
+
|
|
270
|
+
### Deduplication of concurrent calls
|
|
271
|
+
|
|
272
|
+
Calling `__fetchNext()` multiple times concurrently returns the same promise. Only one network request is made per page --- subsequent calls while a request is in flight are deduplicated.
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
// These are the same promise --- only one request is made
|
|
276
|
+
const p1 = result.__fetchNext();
|
|
277
|
+
const p2 = result.__fetchNext();
|
|
278
|
+
p1 === p2; // true
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Append Mode vs Replace Mode
|
|
284
|
+
|
|
285
|
+
The behavior when a new page is loaded depends on the type of array in your result:
|
|
286
|
+
|
|
287
|
+
### Live arrays (`t.liveArray`) --- append mode
|
|
288
|
+
|
|
289
|
+
New entities from the next page are **appended** to the existing array. The array accumulates across pages, giving you the classic infinite scroll behavior. Duplicate entities (same `typename + id`) are deduplicated --- the existing entry is updated in place rather than added again.
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
class GetItems extends RESTQuery {
|
|
293
|
+
path = '/items';
|
|
294
|
+
|
|
295
|
+
result = {
|
|
296
|
+
items: t.liveArray(Item), // Entities accumulate across pages
|
|
297
|
+
nextCursor: t.optional(t.string),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
fetchNext = {
|
|
301
|
+
searchParams: { cursor: this.result.nextCursor },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
After loading three pages with 2 items each, `result.items` contains all 6 items (assuming no duplicates).
|
|
307
|
+
|
|
308
|
+
### Plain arrays (`t.array`) --- replace mode
|
|
309
|
+
|
|
310
|
+
Plain arrays are **replaced** with the new page's data. The previous page's items are discarded.
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
class GetItems extends RESTQuery {
|
|
314
|
+
path = '/items';
|
|
315
|
+
|
|
316
|
+
result = {
|
|
317
|
+
items: t.array(t.string), // Replaced on each fetchNext
|
|
318
|
+
nextPage: t.optional(t.number),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
fetchNext = {
|
|
322
|
+
searchParams: { page: this.result.nextPage },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
After loading a new page, `result.items` contains only the new page's items.
|
|
328
|
+
|
|
329
|
+
{% callout title="Choose the right array type" type="note" %}
|
|
330
|
+
Use `t.liveArray` when you want infinite scroll or "load more" behavior where items accumulate. Use `t.array` when you want traditional pagination where each page replaces the previous one (e.g. a paginated table with "Previous / Next" buttons).
|
|
331
|
+
{% /callout %}
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Scalar Field Updates
|
|
336
|
+
|
|
337
|
+
Non-array fields in the result are always **updated** to the new page's values. This is how cursor advancement works:
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
result = {
|
|
341
|
+
items: t.liveArray(Item),
|
|
342
|
+
nextCursor: t.optional(t.string), // Updated to new cursor on each page
|
|
343
|
+
totalCount: t.number, // Updated to new value on each page
|
|
344
|
+
};
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
After loading the next page:
|
|
348
|
+
|
|
349
|
+
- `nextCursor` is updated to the new cursor value (or `undefined`/`null` on the last page).
|
|
350
|
+
- `totalCount` reflects whatever the new response returned.
|
|
351
|
+
|
|
352
|
+
If the new response omits an optional field entirely, it becomes `undefined`. If it explicitly sends `null` for a nullable field, it becomes `null`.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Cursor Advancement
|
|
357
|
+
|
|
358
|
+
FieldRefs automatically resolve to the **current** result data at the time `__fetchNext()` is called. This means cursors advance naturally across multiple pages:
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
class GetItems extends RESTQuery {
|
|
362
|
+
path = '/items';
|
|
363
|
+
|
|
364
|
+
result = {
|
|
365
|
+
items: t.liveArray(Item),
|
|
366
|
+
cursor: t.optional(t.string),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
fetchNext = {
|
|
370
|
+
searchParams: { cursor: this.result.cursor },
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
1. Initial fetch returns `{ cursor: 'c1' }`.
|
|
376
|
+
2. First `__fetchNext()` sends `?cursor=c1`, response returns `{ cursor: 'c2' }`.
|
|
377
|
+
3. Second `__fetchNext()` sends `?cursor=c2`, response returns `{ cursor: 'c3' }`.
|
|
378
|
+
4. Third `__fetchNext()` sends `?cursor=c3`, response returns `{ cursor: undefined }`.
|
|
379
|
+
5. `__hasNext` becomes `false`. No more pages.
|
|
380
|
+
|
|
381
|
+
You do not need to manually track or update the cursor --- Fetchium handles it automatically through the FieldRef resolution mechanism.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Error Handling
|
|
386
|
+
|
|
387
|
+
If a `__fetchNext()` request fails (network error, server error, etc.), the promise rejects and the existing data is preserved. The array is not corrupted, and the cursor is not advanced.
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
try {
|
|
391
|
+
await result.__fetchNext();
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// The error is from the failed fetch
|
|
394
|
+
console.error('Failed to fetch next page:', error);
|
|
395
|
+
// result.items still contains the previous pages' data
|
|
396
|
+
// result.__hasNext is still true (cursor was not advanced)
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
This means you can safely retry by calling `__fetchNext()` again after a failure --- it will use the same cursor value since the result data was not updated.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Edge Cases
|
|
405
|
+
|
|
406
|
+
### Calling `__fetchNext()` before initial data loads
|
|
407
|
+
|
|
408
|
+
If you call `__fetchNext()` before the initial query has resolved, it throws an error:
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
Cannot call __fetchNext before initial data has loaded
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Always check `query.isReady` or `query.isPending` before accessing `__fetchNext()`.
|
|
415
|
+
|
|
416
|
+
### Calling `__fetchNext()` without pagination configured
|
|
417
|
+
|
|
418
|
+
If neither `fetchNext` nor `getFetchNext()` is defined on the query class, calling `__fetchNext()` throws:
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
fetchNext is not configured
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
In this case, `__hasNext` is always `false` and `__isFetchingNext` is always `false`.
|
|
425
|
+
|
|
426
|
+
### Combining `fetchNext` with `searchParams`
|
|
427
|
+
|
|
428
|
+
When `fetchNext` provides additional `searchParams`, they are **merged** with the query's base search params (from the `searchParams` field or `getSearchParams()` method). The `fetchNext` params take priority for any overlapping keys.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Complete Example
|
|
433
|
+
|
|
434
|
+
Here is a full example showing cursor-based pagination with a live array, entity normalization, and a "load more" UI:
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
import { Entity, t } from 'fetchium';
|
|
438
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
439
|
+
import { useQuery } from 'fetchium/react';
|
|
440
|
+
|
|
441
|
+
// Entity definition
|
|
442
|
+
class Post extends Entity {
|
|
443
|
+
__typename = t.typename('Post');
|
|
444
|
+
id = t.id;
|
|
445
|
+
|
|
446
|
+
title = t.string;
|
|
447
|
+
body = t.string;
|
|
448
|
+
author = t.entity(User);
|
|
449
|
+
createdAt = t.format('date-time');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Query with pagination
|
|
453
|
+
class GetPosts extends RESTQuery {
|
|
454
|
+
params = { userId: t.number };
|
|
455
|
+
|
|
456
|
+
path = `/users/${this.params.userId}/posts`;
|
|
457
|
+
|
|
458
|
+
result = {
|
|
459
|
+
posts: t.liveArray(Post),
|
|
460
|
+
nextCursor: t.nullish(t.string),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
fetchNext = {
|
|
464
|
+
searchParams: {
|
|
465
|
+
cursor: this.result.nextCursor,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
config = {
|
|
470
|
+
staleTime: 30_000,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// React component
|
|
475
|
+
function UserPosts({ userId }: { userId: number }) {
|
|
476
|
+
const query = useQuery(GetPosts, { userId });
|
|
477
|
+
|
|
478
|
+
if (query.isPending) return <div>Loading posts...</div>;
|
|
479
|
+
if (query.isRejected) return <div>Error: {query.error.message}</div>;
|
|
480
|
+
|
|
481
|
+
const { posts, __hasNext, __isFetchingNext, __fetchNext } = query.value;
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div>
|
|
485
|
+
{posts.map((post) => (
|
|
486
|
+
<article key={post.id}>
|
|
487
|
+
<h2>{post.title}</h2>
|
|
488
|
+
<p>{post.body}</p>
|
|
489
|
+
<span>By {post.author.name}</span>
|
|
490
|
+
</article>
|
|
491
|
+
))}
|
|
492
|
+
|
|
493
|
+
{__hasNext && (
|
|
494
|
+
<button onClick={() => __fetchNext()} disabled={__isFetchingNext}>
|
|
495
|
+
{__isFetchingNext ? 'Loading...' : 'Load More Posts'}
|
|
496
|
+
</button>
|
|
497
|
+
)}
|
|
498
|
+
|
|
499
|
+
{!__hasNext && posts.length > 0 && <p>You have reached the end.</p>}
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## Next Steps
|
|
508
|
+
|
|
509
|
+
{% quick-links %}
|
|
510
|
+
|
|
511
|
+
{% quick-link title="Live Data" icon="installation" href="/data/live-data" description="Learn how live arrays and live values keep your UI in sync" /%}
|
|
512
|
+
|
|
513
|
+
{% quick-link title="Queries" icon="presets" href="/core/queries" description="Full reference for query definitions, caching, and configuration" /%}
|
|
514
|
+
|
|
515
|
+
{% quick-link title="Types" icon="plugins" href="/core/types" description="The full type system for params, results, and entity fields" /%}
|
|
516
|
+
|
|
517
|
+
{% quick-link title="Entities" icon="theming" href="/core/entities" description="Normalized entity caching and identity-stable proxies" /%}
|
|
518
|
+
|
|
519
|
+
{% /quick-links %}
|