fetchium 0.1.0 → 0.1.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +6 -5
  2. package/dist/cjs/development/QueryClient-CLi3ONNM.js +2 -0
  3. package/dist/cjs/development/QueryClient-CLi3ONNM.js.map +1 -0
  4. package/dist/cjs/development/QueryController-BQA49OYU.js +2 -0
  5. package/dist/cjs/development/QueryController-BQA49OYU.js.map +1 -0
  6. package/dist/cjs/development/index.js +1 -1
  7. package/dist/cjs/development/index.js.map +1 -1
  8. package/dist/cjs/development/mutation-CikIl_6k.js +2 -0
  9. package/dist/cjs/development/mutation-CikIl_6k.js.map +1 -0
  10. package/dist/cjs/development/react/index.js +1 -1
  11. package/dist/cjs/development/rest/index.js +2 -0
  12. package/dist/cjs/development/rest/index.js.map +1 -0
  13. package/dist/cjs/development/topic/index.js +2 -0
  14. package/dist/cjs/development/topic/index.js.map +1 -0
  15. package/dist/cjs/production/QueryClient-N0MJmuHW.js +2 -0
  16. package/dist/cjs/production/QueryClient-N0MJmuHW.js.map +1 -0
  17. package/dist/cjs/production/QueryController-BQA49OYU.js +2 -0
  18. package/dist/cjs/production/QueryController-BQA49OYU.js.map +1 -0
  19. package/dist/cjs/production/index.js +1 -1
  20. package/dist/cjs/production/index.js.map +1 -1
  21. package/dist/cjs/production/mutation-P_Yb4LI9.js +2 -0
  22. package/dist/cjs/production/mutation-P_Yb4LI9.js.map +1 -0
  23. package/dist/cjs/production/react/index.js +1 -1
  24. package/dist/cjs/production/rest/index.js +2 -0
  25. package/dist/cjs/production/rest/index.js.map +1 -0
  26. package/dist/cjs/production/topic/index.js +2 -0
  27. package/dist/cjs/production/topic/index.js.map +1 -0
  28. package/dist/esm/MutationResult.d.ts +0 -1
  29. package/dist/esm/MutationResult.d.ts.map +1 -1
  30. package/dist/esm/QueryClient.d.ts +26 -4
  31. package/dist/esm/QueryClient.d.ts.map +1 -1
  32. package/dist/esm/QueryController.d.ts +49 -0
  33. package/dist/esm/QueryController.d.ts.map +1 -0
  34. package/dist/esm/QueryResult.d.ts +10 -10
  35. package/dist/esm/QueryResult.d.ts.map +1 -1
  36. package/dist/esm/development/QueryClient-Dtde3pss.js +2572 -0
  37. package/dist/esm/development/QueryClient-Dtde3pss.js.map +1 -0
  38. package/dist/esm/development/QueryController-Ch_ncxiI.js +14 -0
  39. package/dist/esm/development/QueryController-Ch_ncxiI.js.map +1 -0
  40. package/dist/esm/development/index.js +29 -100
  41. package/dist/esm/development/index.js.map +1 -1
  42. package/dist/esm/development/mutation-UZshUQAf.js +58 -0
  43. package/dist/esm/development/mutation-UZshUQAf.js.map +1 -0
  44. package/dist/esm/development/react/index.js +1 -1
  45. package/dist/esm/development/rest/index.js +142 -0
  46. package/dist/esm/development/rest/index.js.map +1 -0
  47. package/dist/esm/development/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  48. package/dist/esm/development/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  49. package/dist/esm/development/stores/async.js +6 -6
  50. package/dist/esm/development/stores/sync.js +5 -5
  51. package/dist/esm/development/topic/index.js +86 -0
  52. package/dist/esm/development/topic/index.js.map +1 -0
  53. package/dist/esm/index.d.ts +5 -4
  54. package/dist/esm/index.d.ts.map +1 -1
  55. package/dist/esm/mutation.d.ts +6 -19
  56. package/dist/esm/mutation.d.ts.map +1 -1
  57. package/dist/esm/production/{QueryClient-BP0Z1rQV.js → QueryClient-YqnBxFy1.js} +972 -968
  58. package/dist/esm/production/QueryClient-YqnBxFy1.js.map +1 -0
  59. package/dist/esm/production/QueryController-Ch_ncxiI.js +14 -0
  60. package/dist/esm/production/QueryController-Ch_ncxiI.js.map +1 -0
  61. package/dist/esm/production/index.js +29 -100
  62. package/dist/esm/production/index.js.map +1 -1
  63. package/dist/esm/production/mutation-pgFl1uIY.js +58 -0
  64. package/dist/esm/production/mutation-pgFl1uIY.js.map +1 -0
  65. package/dist/esm/production/react/index.js +1 -1
  66. package/dist/esm/production/rest/index.js +142 -0
  67. package/dist/esm/production/rest/index.js.map +1 -0
  68. package/dist/esm/production/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  69. package/dist/esm/production/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  70. package/dist/esm/production/stores/async.js +6 -6
  71. package/dist/esm/production/stores/sync.js +5 -5
  72. package/dist/esm/production/topic/index.js +86 -0
  73. package/dist/esm/production/topic/index.js.map +1 -0
  74. package/dist/esm/query-types.d.ts +2 -4
  75. package/dist/esm/query-types.d.ts.map +1 -1
  76. package/dist/esm/query.d.ts +17 -39
  77. package/dist/esm/query.d.ts.map +1 -1
  78. package/dist/esm/rest/RESTMutation.d.ts +18 -0
  79. package/dist/esm/rest/RESTMutation.d.ts.map +1 -0
  80. package/dist/esm/rest/RESTQuery.d.ts +24 -0
  81. package/dist/esm/rest/RESTQuery.d.ts.map +1 -0
  82. package/dist/esm/rest/RESTQueryController.d.ts +34 -0
  83. package/dist/esm/rest/RESTQueryController.d.ts.map +1 -0
  84. package/dist/esm/rest/index.d.ts +5 -0
  85. package/dist/esm/rest/index.d.ts.map +1 -0
  86. package/dist/esm/stores/shared.d.ts.map +1 -1
  87. package/dist/esm/testing/MockClient.d.ts +64 -0
  88. package/dist/esm/testing/MockClient.d.ts.map +1 -0
  89. package/dist/esm/testing/auto-generate.d.ts +20 -0
  90. package/dist/esm/testing/auto-generate.d.ts.map +1 -0
  91. package/dist/esm/testing/entity-factory.d.ts +13 -0
  92. package/dist/esm/testing/entity-factory.d.ts.map +1 -0
  93. package/dist/esm/testing/index.d.ts +6 -0
  94. package/dist/esm/testing/index.d.ts.map +1 -0
  95. package/dist/esm/testing/types.d.ts +37 -0
  96. package/dist/esm/testing/types.d.ts.map +1 -0
  97. package/dist/esm/topic/TopicQuery.d.ts +10 -0
  98. package/dist/esm/topic/TopicQuery.d.ts.map +1 -0
  99. package/dist/esm/topic/TopicQueryController.d.ts +43 -0
  100. package/dist/esm/topic/TopicQueryController.d.ts.map +1 -0
  101. package/dist/esm/topic/index.d.ts +3 -0
  102. package/dist/esm/topic/index.d.ts.map +1 -0
  103. package/dist/esm/typeDefs.d.ts +1 -1
  104. package/dist/esm/types.d.ts +9 -4
  105. package/dist/esm/types.d.ts.map +1 -1
  106. package/package.json +51 -4
  107. package/plugin/.claude-plugin/plugin.json +10 -0
  108. package/plugin/agents/fetchium.md +168 -0
  109. package/plugin/docs/api/fetchium-react.md +135 -0
  110. package/plugin/docs/api/fetchium.md +674 -0
  111. package/plugin/docs/api/stores-async.md +219 -0
  112. package/plugin/docs/api/stores-sync.md +133 -0
  113. package/plugin/docs/core/entities.md +351 -0
  114. package/plugin/docs/core/queries.md +600 -0
  115. package/plugin/docs/core/streaming.md +550 -0
  116. package/plugin/docs/core/types.md +374 -0
  117. package/plugin/docs/data/caching.md +298 -0
  118. package/plugin/docs/data/live-data.md +435 -0
  119. package/plugin/docs/data/mutations.md +465 -0
  120. package/plugin/docs/guides/auth.md +318 -0
  121. package/plugin/docs/guides/error-handling.md +351 -0
  122. package/plugin/docs/guides/offline.md +270 -0
  123. package/plugin/docs/guides/testing.md +301 -0
  124. package/plugin/docs/quickstart.md +170 -0
  125. package/plugin/docs/reference/pagination.md +519 -0
  126. package/plugin/docs/reference/rest-queries.md +107 -0
  127. package/plugin/docs/reference/why-signalium.md +364 -0
  128. package/plugin/docs/setup/project-setup.md +319 -0
  129. package/plugin/install.mjs +88 -0
  130. package/plugin/skills/design/SKILL.md +140 -0
  131. package/plugin/skills/teach/SKILL.md +105 -0
  132. package/dist/cjs/development/QueryClient-CpmwggOn.js +0 -2
  133. package/dist/cjs/development/QueryClient-CpmwggOn.js.map +0 -1
  134. package/dist/cjs/production/QueryClient-qi3bR0eD.js +0 -2
  135. package/dist/cjs/production/QueryClient-qi3bR0eD.js.map +0 -1
  136. package/dist/esm/development/QueryClient-DRZtPKFD.js +0 -2568
  137. package/dist/esm/development/QueryClient-DRZtPKFD.js.map +0 -1
  138. 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 `RESTQueryController` 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 %}