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,318 @@
1
+ ---
2
+ title: Auth & Headers
3
+ ---
4
+
5
+ In most data-fetching libraries, authentication is handled through interceptors, middleware chains, or framework-specific hooks. Fetchium takes a different approach: authentication is handled through the `fetch` function you pass to the `QueryClient`.
6
+
7
+ This is intentional. Rather than adding a framework-specific interceptor system, Fetchium leverages the web platform's standard `fetch` API. Your auth logic is a _plain JavaScript function_ --- testable, portable, and completely decoupled from the library. You can unit test it without importing Fetchium, reuse it across projects, or swap it out without touching a single query definition.
8
+
9
+ This page covers the common patterns for adding authentication and custom headers to your Fetchium requests, from the simplest static token all the way through reactive auth state and multi-backend configurations.
10
+
11
+ ---
12
+
13
+ ## Global Headers via a Fetch Wrapper
14
+
15
+ The simplest and most common pattern is wrapping the native `fetch` with a function that injects your auth token on every request. You pass this wrapper to the `QueryClient` at setup time, and every query uses it automatically.
16
+
17
+ ```ts
18
+ function createAuthFetch(getToken: () => string | null) {
19
+ return async (url: RequestInfo, init?: RequestInit) => {
20
+ const token = getToken();
21
+ const headers = new Headers(init?.headers);
22
+
23
+ if (token) {
24
+ headers.set('Authorization', `Bearer ${token}`);
25
+ }
26
+
27
+ return fetch(url, { ...init, headers });
28
+ };
29
+ }
30
+
31
+ const client = new QueryClient(store, {
32
+ fetch: createAuthFetch(() => localStorage.getItem('auth_token')),
33
+ baseUrl: 'https://api.example.com',
34
+ });
35
+ ```
36
+
37
+ Every query that runs through this client will include the `Authorization` header whenever a token is available. If the token is `null` (e.g. the user is logged out), the header is simply omitted.
38
+
39
+ Notice that `createAuthFetch` accepts a _getter function_ rather than the token directly. This is important --- the token is read at request time, not at client creation time, so it always reflects the current value.
40
+
41
+ ---
42
+
43
+ ## API Key Pattern
44
+
45
+ If your API uses a static key rather than a user token, the pattern is even simpler:
46
+
47
+ ```ts
48
+ const client = new QueryClient(store, {
49
+ fetch: async (url, init) => {
50
+ const headers = new Headers(init?.headers);
51
+ headers.set('X-API-Key', process.env.API_KEY!);
52
+ return fetch(url, { ...init, headers });
53
+ },
54
+ baseUrl: 'https://api.example.com',
55
+ });
56
+ ```
57
+
58
+ This works well for server-side usage or public APIs that require an API key but not user-level authentication.
59
+
60
+ ---
61
+
62
+ ## Reactive Auth with Signalium Signals
63
+
64
+ In a single-page application, the auth token is not static --- it changes when users log in, log out, or when a refresh token rotates. If you are using Signalium for state management, you can make your auth state _reactive_ so that queries automatically respond to token changes.
65
+
66
+ ```ts
67
+ import { signal } from 'signalium';
68
+
69
+ const authToken = signal<string | null>(null);
70
+
71
+ function login(token: string) {
72
+ authToken.set(token);
73
+ }
74
+
75
+ function logout() {
76
+ authToken.set(null);
77
+ }
78
+ ```
79
+
80
+ Then use the signal's value in your fetch wrapper:
81
+
82
+ ```ts
83
+ function createReactiveAuthFetch() {
84
+ return async (url: RequestInfo, init?: RequestInit) => {
85
+ const token = authToken.value;
86
+ const headers = new Headers(init?.headers);
87
+
88
+ if (token) {
89
+ headers.set('Authorization', `Bearer ${token}`);
90
+ }
91
+
92
+ return fetch(url, { ...init, headers });
93
+ };
94
+ }
95
+
96
+ const client = new QueryClient(store, {
97
+ fetch: createReactiveAuthFetch(),
98
+ baseUrl: 'https://api.example.com',
99
+ });
100
+ ```
101
+
102
+ Because `authToken` is a Signalium signal, any reactive computation that reads `authToken.value` establishes a dependency on it. When the token changes --- say, after a login --- active queries that depend on authenticated data will know to refetch with the new credentials.
103
+
104
+ ---
105
+
106
+ ## Per-Query Headers
107
+
108
+ Sometimes individual queries need headers beyond the global auth token. For example, a file upload endpoint might require a specific `Content-Type`, or a particular API version header.
109
+
110
+ Fetchium handles this through the `headers` field on your query class:
111
+
112
+ ```ts
113
+ class UploadAvatar extends RESTQuery {
114
+ params = {
115
+ userId: t.number,
116
+ contentType: t.string,
117
+ };
118
+
119
+ path = `/users/${this.params.userId}/avatar`;
120
+ method = 'PUT';
121
+
122
+ headers = {
123
+ 'Content-Type': this.params.contentType,
124
+ 'X-Upload-Source': 'web-client',
125
+ };
126
+
127
+ result = {
128
+ avatarUrl: t.string,
129
+ };
130
+ }
131
+ ```
132
+
133
+ The layering is straightforward: your global fetch wrapper handles _auth_ (the concern that applies everywhere), and per-query headers handle _API-specific needs_ (the concerns that vary by endpoint). The two are composed naturally --- `headers` from the query class are passed through `init.headers` to your fetch wrapper, which can merge them with auth headers using `new Headers(init?.headers)`.
134
+
135
+ For dynamic per-query headers that depend on runtime conditions, use the `getHeaders()` method:
136
+
137
+ ```ts
138
+ class GetReport extends RESTQuery {
139
+ params = {
140
+ reportId: t.number,
141
+ format: t.optional(t.string),
142
+ };
143
+
144
+ path = `/reports/${this.params.reportId}`;
145
+
146
+ getHeaders() {
147
+ const headers: Record<string, string> = {};
148
+
149
+ if (this.params.format === 'csv') {
150
+ headers['Accept'] = 'text/csv';
151
+ }
152
+
153
+ return headers;
154
+ }
155
+
156
+ result = {
157
+ data: t.string,
158
+ };
159
+ }
160
+ ```
161
+
162
+ As described in the [Queries](/core/queries) guide, every field on `RESTQuery` has a corresponding `get*()` method for when you need logic that goes beyond simple references and interpolations.
163
+
164
+ ---
165
+
166
+ ## Handling 401 and Token Refresh
167
+
168
+ A common requirement is catching `401 Unauthorized` responses, refreshing the auth token, and retrying the original request. Because your fetch wrapper is just a function, this is standard fetch composition --- Fetchium doesn't need a special API for it.
169
+
170
+ ```ts
171
+ function createAuthFetchWithRefresh(
172
+ getToken: () => string | null,
173
+ refreshToken: () => Promise<string>,
174
+ setToken: (token: string) => void,
175
+ ) {
176
+ let refreshPromise: Promise<string> | null = null;
177
+
178
+ return async (url: RequestInfo, init?: RequestInit): Promise<Response> => {
179
+ const token = getToken();
180
+ const headers = new Headers(init?.headers);
181
+
182
+ if (token) {
183
+ headers.set('Authorization', `Bearer ${token}`);
184
+ }
185
+
186
+ const response = await fetch(url, { ...init, headers });
187
+
188
+ if (response.status === 401 && token) {
189
+ // Deduplicate concurrent refresh attempts
190
+ if (!refreshPromise) {
191
+ refreshPromise = refreshToken().finally(() => {
192
+ refreshPromise = null;
193
+ });
194
+ }
195
+
196
+ const newToken = await refreshPromise;
197
+ setToken(newToken);
198
+
199
+ // Retry with the new token
200
+ headers.set('Authorization', `Bearer ${newToken}`);
201
+ return fetch(url, { ...init, headers });
202
+ }
203
+
204
+ return response;
205
+ };
206
+ }
207
+ ```
208
+
209
+ A few things to note in this pattern:
210
+
211
+ - **Deduplication**: If multiple queries receive 401s simultaneously (common after a token expires), only one refresh request is made. The others await the same promise.
212
+ - **Single retry**: The request is retried exactly once with the new token. If the retry also fails, the error propagates normally.
213
+ - **No Fetchium-specific code**: This function knows nothing about queries or signals. You could use it with plain `fetch` calls in a completely different project.
214
+
215
+ Wire it into your client the same way:
216
+
217
+ ```ts
218
+ const client = new QueryClient(store, {
219
+ fetch: createAuthFetchWithRefresh(
220
+ () => authToken.value,
221
+ () => api.refreshSession(),
222
+ (token) => authToken.set(token),
223
+ ),
224
+ baseUrl: 'https://api.example.com',
225
+ });
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Multiple API Backends
231
+
232
+ If your application talks to multiple APIs with different auth schemes --- for instance, your own backend with JWT auth and a third-party service with an API key --- you have two options.
233
+
234
+ ### Separate QueryClients
235
+
236
+ The cleanest approach is creating a dedicated `QueryClient` for each backend:
237
+
238
+ ```ts
239
+ const appClient = new QueryClient(appStore, {
240
+ fetch: createAuthFetch(() => authToken.value),
241
+ baseUrl: 'https://api.myapp.com',
242
+ });
243
+
244
+ const analyticsClient = new QueryClient(analyticsStore, {
245
+ fetch: async (url, init) => {
246
+ const headers = new Headers(init?.headers);
247
+ headers.set('X-API-Key', ANALYTICS_API_KEY);
248
+ return fetch(url, { ...init, headers });
249
+ },
250
+ baseUrl: 'https://analytics.example.com',
251
+ });
252
+ ```
253
+
254
+ Each client has its own store, auth, and base URL. Queries associated with each client are completely independent. In React, you would provide each client through its own `ContextProvider`.
255
+
256
+ ### Per-query `baseUrl`
257
+
258
+ If you only need to override the URL for a few queries and the auth scheme is the same, you can set `baseUrl` on individual queries via `requestOptions`:
259
+
260
+ ```ts
261
+ class GetAnalytics extends RESTQuery {
262
+ params = { eventType: t.string };
263
+
264
+ path = `/events/${this.params.eventType}`;
265
+
266
+ requestOptions = {
267
+ baseUrl: 'https://analytics.example.com',
268
+ };
269
+
270
+ result = {
271
+ count: t.number,
272
+ };
273
+ }
274
+ ```
275
+
276
+ This query will use the alternate base URL but still go through the same `QueryClient` and its fetch wrapper. This works well when the auth requirements are shared but the hosts differ.
277
+
278
+ ---
279
+
280
+ {% callout title="Why no interceptors?" type="note" %}
281
+ If you are coming from Axios, you may be used to interceptors --- a middleware chain that processes requests and responses. Fetchium deliberately avoids this pattern.
282
+
283
+ Interceptors introduce ordering complexity (which interceptor runs first?), make testing harder (you have to mock the interceptor chain), and add a layer of abstraction that obscures what your code actually does. A plain fetch wrapper achieves the same thing: you can inspect requests, modify headers, handle errors, retry, and log --- all in a single function with an obvious control flow.
284
+
285
+ This is a deliberate design decision. Fetchium favors _composition over configuration_. Instead of learning a framework-specific interceptor API, you compose standard JavaScript functions. The result is code that is easier to read, easier to test, and easier to change.
286
+ {% /callout %}
287
+
288
+ ---
289
+
290
+ ## Summary
291
+
292
+ | Pattern | When to use |
293
+ | ---------------------------------- | ------------------------------------------------------------------ |
294
+ | Global fetch wrapper | Auth that applies to all requests (JWT, session cookies, API keys) |
295
+ | Reactive signal token | SPAs where auth state changes at runtime (login/logout) |
296
+ | Per-query `headers` | Endpoint-specific headers (content types, API versions) |
297
+ | `getHeaders()` method | Dynamic per-query headers based on runtime conditions |
298
+ | 401 catch + refresh + retry | Token expiration with automatic renewal |
299
+ | Multiple `QueryClient` instances | Different APIs with different auth schemes or stores |
300
+ | Per-query `requestOptions.baseUrl` | Same auth, different host |
301
+
302
+ The common thread is that Fetchium does not own your auth logic. It provides the _seam_ --- the `fetch` option on `QueryClient` --- and you fill it with whatever your application needs. This keeps the library small, your auth testable, and your options open.
303
+
304
+ ---
305
+
306
+ ## Next Steps
307
+
308
+ {% quick-links %}
309
+
310
+ {% quick-link title="Queries" icon="presets" href="/core/queries" description="Learn how to define queries, configure caching, and fetch data" /%}
311
+
312
+ {% quick-link title="Error Handling" icon="warning" href="/guides/error-handling" description="Handle network failures, retries, and error boundaries" /%}
313
+
314
+ {% quick-link title="Testing" icon="installation" href="/guides/testing" description="Test your queries, auth wrappers, and components" /%}
315
+
316
+ {% quick-link title="Offline & Persistence" icon="plugins" href="/guides/offline" description="Keep your app working without a network connection" /%}
317
+
318
+ {% /quick-links %}
@@ -0,0 +1,351 @@
1
+ ---
2
+ title: Error Handling
3
+ ---
4
+
5
+ Fetchium draws a deliberate line between errors that should _break_ the UI and errors that should be _absorbed_.
6
+
7
+ Network failures and server errors break the UI --- they reject the query and set `isRejected` to `true`, because the user needs to know that something went wrong. Parse failures for optional fields and array items, on the other hand, are absorbed silently --- they fall back to `undefined` or are filtered out, because showing _nothing_ is a better default than crashing.
8
+
9
+ This is the same resilience philosophy described in the [Types guide](/core/types). A 500 from your server is genuinely broken; a new enum value your client doesn't recognize yet is not. Fetchium's error system is designed around this distinction so you can build UIs that degrade gracefully without sacrificing visibility into real failures.
10
+
11
+ ---
12
+
13
+ ## ReactivePromise Error States
14
+
15
+ Every query in Fetchium returns a `ReactivePromise`, which exposes a small set of properties for handling async state --- including errors:
16
+
17
+ | Property | Type | Description |
18
+ | ------------ | --------- | -------------------------------------------------------------------------------------------------- |
19
+ | `isRejected` | `boolean` | `true` when the most recent execution failed (after all retries are exhausted) |
20
+ | `error` | `unknown` | The error object from the rejection. Only meaningful when `isRejected` is `true` |
21
+ | `isPending` | `boolean` | `true` during loading, including retry attempts |
22
+ | `isReady` | `boolean` | `true` once data has loaded successfully at least once --- stays `true` even across later failures |
23
+ | `value` | `T` | The most recently resolved value. Available when `isReady` is `true`, even if a later fetch fails |
24
+
25
+ The standard pattern for handling errors in components follows directly from these properties:
26
+
27
+ ```tsx {% mode="react" %}
28
+ import { useQuery } from 'fetchium/react';
29
+
30
+ function UserProfile({ userId }: { userId: number }) {
31
+ const result = useQuery(GetUser, { id: userId });
32
+
33
+ if (result.isRejected) return <div>Error: {result.error.message}</div>;
34
+ if (!result.isReady) return <div>Loading...</div>;
35
+
36
+ return (
37
+ <div>
38
+ <h1>{result.value.name}</h1>
39
+ <p>{result.value.email}</p>
40
+ </div>
41
+ );
42
+ }
43
+ ```
44
+
45
+ ```tsx {% mode="signalium" %}
46
+ import { fetchQuery } from 'fetchium';
47
+ import { component } from 'signalium/react';
48
+
49
+ const UserProfile = component(({ userId }: { userId: number }) => {
50
+ const result = fetchQuery(GetUser, { id: userId });
51
+
52
+ if (result.isRejected) return <div>Error: {result.error.message}</div>;
53
+ if (!result.isReady) return <div>Loading...</div>;
54
+
55
+ return (
56
+ <div>
57
+ <h1>{result.value.name}</h1>
58
+ <p>{result.value.email}</p>
59
+ </div>
60
+ );
61
+ });
62
+ ```
63
+
64
+ Note the order: check `isRejected` _first_, then `isReady`. This ensures that a hard failure is always surfaced to the user, even if stale data exists from a previous successful fetch.
65
+
66
+ ---
67
+
68
+ ## Types of Errors
69
+
70
+ Not all errors are equal. Fetchium distinguishes between three categories, and each surfaces differently.
71
+
72
+ ### Network errors
73
+
74
+ These occur when `fetch` itself fails --- the device is offline, DNS resolution fails, the connection times out. The browser throws a `TypeError` (or similar), and Fetchium treats it as a rejection. After retries are exhausted, `isRejected` becomes `true` and `error` contains the original `TypeError`.
75
+
76
+ Network errors are the most common reason for a query to be in a retrying state. While retries are in progress, `isPending` is `true` and any previously loaded `value` remains available.
77
+
78
+ ### HTTP errors
79
+
80
+ The server responds, but with a non-success status code (4xx, 5xx). By default, `RESTQuery` treats any response where `response.ok` is `false` as an error. The query rejects with an error that includes the status code and response body.
81
+
82
+ HTTP errors are particularly useful for distinguishing between client mistakes (400, 404, 422) and server failures (500, 502, 503). You can inspect the error in your component or intercept specific status codes globally via the fetch wrapper (see [Global Error Handling](#global-error-handling-via-the-fetch-wrapper) below).
83
+
84
+ ### Parse errors
85
+
86
+ These are the most nuanced category. When a response body doesn't match the type definition, the behavior depends on the _context_ of the mismatch:
87
+
88
+ - **Required fields** --- If a top-level required field fails to parse (e.g. the server returns `null` for a `t.string` field), the entire response is treated as a parse error and the query rejects.
89
+ - **Optional fields** --- If an optional field (`t.optional(...)`) fails to parse, it silently falls back to `undefined`. No error is surfaced.
90
+ - **Array items** --- If an individual item in an array fails to parse, it is silently filtered out. The rest of the array is returned normally.
91
+
92
+ This design means that additive API changes --- a new enum value in an array, a new optional field with an unexpected shape --- won't crash older clients. The [Types guide](/core/types) covers this philosophy in depth.
93
+
94
+ {% callout title="When you want explicit parse errors" type="note" %}
95
+ If you need to handle parse failures explicitly rather than silently, wrap the field in `t.result(...)` instead of `t.optional(...)`. This returns a `ParseResult<T>` with a `success` flag and either a `value` or `error`, forcing you to handle the failure case in your code.
96
+ {% /callout %}
97
+
98
+ ---
99
+
100
+ ## Retry Configuration
101
+
102
+ Queries retry failed requests automatically. The defaults are:
103
+
104
+ | Environment | Default retries |
105
+ | ----------- | --------------- |
106
+ | Client | 3 |
107
+ | Server | 0 |
108
+
109
+ Server-side queries don't retry because SSR has strict time budgets --- it's better to fail fast and let the client handle recovery.
110
+
111
+ ### Configuring retries
112
+
113
+ Retry behavior is controlled via the `config` field on your query class. The simplest form is a count:
114
+
115
+ ```ts
116
+ class GetUser extends RESTQuery {
117
+ params = { id: t.number };
118
+
119
+ path = `/users/${this.params.id}`;
120
+
121
+ result = { name: t.string };
122
+
123
+ config = {
124
+ retry: 5,
125
+ };
126
+ }
127
+ ```
128
+
129
+ For more control, pass an object with `retries` and an optional `retryDelay` function:
130
+
131
+ ```ts
132
+ class GetUser extends RESTQuery {
133
+ params = { id: t.number };
134
+
135
+ path = `/users/${this.params.id}`;
136
+
137
+ result = { name: t.string };
138
+
139
+ config = {
140
+ retry: {
141
+ retries: 3,
142
+ retryDelay: (attempt) => 1000 * Math.pow(2, attempt),
143
+ },
144
+ };
145
+ }
146
+ ```
147
+
148
+ The default retry delay uses exponential backoff --- each successive attempt waits longer than the last (roughly 1s, 2s, 4s, ...). This avoids hammering a struggling server with rapid retries.
149
+
150
+ To disable retries entirely:
151
+
152
+ ```ts
153
+ config = {
154
+ retry: false,
155
+ };
156
+ ```
157
+
158
+ ### Mutations do not retry
159
+
160
+ Mutations have retries disabled by default. This is a deliberate safety decision --- retrying a `POST` or `DELETE` that may have partially succeeded on the server can cause duplicate writes or unintended side effects. If you need retry behavior on a mutation, you can opt in explicitly, but the default is `retry: false`.
161
+
162
+ ---
163
+
164
+ ## The Relationship Between Errors and Retries
165
+
166
+ Retries and error states interact in a specific way that is important to understand:
167
+
168
+ 1. A query fails its initial fetch attempt.
169
+ 2. `isPending` remains `true` while retries are in progress. If the query had previously loaded data, `isReady` stays `true` and `value` still holds the last successful result.
170
+ 3. If a retry succeeds, the query resolves normally --- `isResolved` becomes `true`, `value` updates, and the error is cleared.
171
+ 4. If all retries are exhausted, the _final_ error is surfaced: `isRejected` becomes `true` and `error` is set.
172
+
173
+ This means your UI can continue showing stale data while retries happen in the background. A common pattern is to show a subtle "refreshing" indicator alongside the existing content, and only show the error state if the query has _never_ loaded successfully:
174
+
175
+ ```tsx {% mode="react" %}
176
+ function UserProfile({ userId }: { userId: number }) {
177
+ const result = useQuery(GetUser, { id: userId });
178
+
179
+ if (result.isRejected && !result.isReady) {
180
+ return <div>Failed to load: {result.error.message}</div>;
181
+ }
182
+
183
+ if (!result.isReady) return <div>Loading...</div>;
184
+
185
+ return (
186
+ <div>
187
+ {result.isRejected && (
188
+ <div className="warning">
189
+ Could not refresh data. Showing cached results.
190
+ </div>
191
+ )}
192
+ <h1>{result.value.name}</h1>
193
+ <p>{result.value.email}</p>
194
+ </div>
195
+ );
196
+ }
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Global Error Handling via the Fetch Wrapper
202
+
203
+ Sometimes you need to intercept errors _before_ they reach individual queries --- for instance, redirecting to a login page on a 401, refreshing an auth token, or logging all failures to a telemetry service.
204
+
205
+ The `QueryClient` accepts a `fetch` function, which is the standard place to add global error handling. You can wrap the native `fetch` with your own logic:
206
+
207
+ ```ts
208
+ function createFetchWithErrorHandling(baseFetch: typeof fetch) {
209
+ return async (url: RequestInfo, init?: RequestInit) => {
210
+ const response = await baseFetch(url, init);
211
+
212
+ if (response.status === 401) {
213
+ // Redirect to login, refresh token, etc.
214
+ }
215
+
216
+ if (response.status === 403) {
217
+ // Handle forbidden access
218
+ }
219
+
220
+ return response;
221
+ };
222
+ }
223
+ ```
224
+
225
+ Then pass it when constructing the client:
226
+
227
+ ```tsx
228
+ import { QueryClient, QueryClientContext } from 'fetchium';
229
+ import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
230
+ import { ContextProvider } from 'signalium/react';
231
+
232
+ const store = new SyncQueryStore(new MemoryPersistentStore());
233
+ const customFetch = createFetchWithErrorHandling(fetch);
234
+ const client = new QueryClient(store, { fetch: customFetch });
235
+
236
+ function App() {
237
+ return (
238
+ <ContextProvider value={client} context={QueryClientContext}>
239
+ <YourApp />
240
+ </ContextProvider>
241
+ );
242
+ }
243
+ ```
244
+
245
+ This approach keeps error handling centralized. Individual queries don't need to know about auth flows or telemetry --- they just see a normal `Response` or a rejection.
246
+
247
+ ---
248
+
249
+ ## Parse Failure Logging
250
+
251
+ Non-fatal parse failures (optional fields falling back to `undefined`, array items being filtered out) happen silently by default. In production, you'll likely want to know about them --- a sudden spike in parse failures can indicate a breaking API change that technically doesn't crash but does degrade the user experience.
252
+
253
+ Fetchium routes these warnings through `QueryContext.log.warn`. You can plug in a custom logger when creating the `QueryClient`:
254
+
255
+ ```ts
256
+ const client = new QueryClient(store, {
257
+ fetch,
258
+ log: {
259
+ warn: (message: string, ...args: unknown[]) => {
260
+ console.warn(message, ...args);
261
+ telemetry.trackWarning('parse_failure', { message, args });
262
+ },
263
+ error: (message: string, ...args: unknown[]) => {
264
+ console.error(message, ...args);
265
+ telemetry.trackError('query_error', { message, args });
266
+ },
267
+ },
268
+ });
269
+ ```
270
+
271
+ The `log.warn` handler receives structured information about what field failed, what value was received, and what type was expected. This gives you enough context to set up alerts for unusual patterns without adding try/catch blocks throughout your codebase.
272
+
273
+ ---
274
+
275
+ ## React Error Boundaries
276
+
277
+ React's error boundary system provides a last-resort catch for unhandled exceptions during rendering. Fetchium's `ReactivePromise` is designed to work _alongside_ error boundaries, but with an important distinction.
278
+
279
+ ### Default behavior: explicit error handling
280
+
281
+ By default, reading properties on a `ReactivePromise` does _not_ throw. When a query fails, `isRejected` becomes `true` and `error` is set, but no exception is raised. This is deliberate --- it gives you full control over how errors are presented in your UI.
282
+
283
+ ```tsx
284
+ const result = useQuery(GetUser, { id: 42 });
285
+
286
+ // No exception thrown. You check the state explicitly:
287
+ if (result.isRejected) {
288
+ return <ErrorMessage error={result.error} />;
289
+ }
290
+ ```
291
+
292
+ ### Opting into throw behavior
293
+
294
+ If you _want_ error boundaries to catch query failures --- for example, when using React Suspense or when you prefer a centralized error UI --- you can read `.value` directly. Reading `.value` on a rejected `ReactivePromise` throws the error, which will propagate up to the nearest error boundary.
295
+
296
+ ```tsx
297
+ import { ErrorBoundary } from 'react-error-boundary';
298
+
299
+ function UserProfile({ userId }: { userId: number }) {
300
+ const result = useQuery(GetUser, { id: userId });
301
+
302
+ // This throws if the query is rejected,
303
+ // which the ErrorBoundary above will catch
304
+ const user = result.value;
305
+
306
+ return (
307
+ <div>
308
+ <h1>{user.name}</h1>
309
+ </div>
310
+ );
311
+ }
312
+
313
+ function App() {
314
+ return (
315
+ <ErrorBoundary fallback={<div>Something went wrong.</div>}>
316
+ <UserProfile userId={42} />
317
+ </ErrorBoundary>
318
+ );
319
+ }
320
+ ```
321
+
322
+ This is a conscious opt-in. The default explicit-checking pattern (`isRejected` + `error`) is recommended for most use cases because it gives you the most flexibility. Error boundaries are best reserved for catching truly unexpected failures that shouldn't be handled inline.
323
+
324
+ ---
325
+
326
+ ## Summary
327
+
328
+ | Error type | Behavior | Surfaces as |
329
+ | ---------------------- | ----------------------------------- | ------------------------------- |
330
+ | Network error | Retried, then rejected | `isRejected: true`, `error` set |
331
+ | HTTP error (4xx/5xx) | Retried (by default), then rejected | `isRejected: true`, `error` set |
332
+ | Parse error (required) | Query rejects | `isRejected: true`, `error` set |
333
+ | Parse error (optional) | Falls back to `undefined` | Silent, logged via `log.warn` |
334
+ | Parse error (array) | Item filtered out | Silent, logged via `log.warn` |
335
+ | Mutation failure | Not retried by default | `isRejected: true`, `error` set |
336
+
337
+ ---
338
+
339
+ ## Next Steps
340
+
341
+ {% quick-links %}
342
+
343
+ {% quick-link title="Types" icon="presets" href="/core/types" description="The resilient type system and parse behavior that drives error handling" /%}
344
+
345
+ {% quick-link title="Queries" icon="plugins" href="/core/queries" description="Query definitions, caching, retry, and configuration options" /%}
346
+
347
+ {% quick-link title="Offline & Persistence" icon="installation" href="/guides/offline" description="Network detection, offline mode, and persistent query storage" /%}
348
+
349
+ {% quick-link title="REST Queries Reference" icon="theming" href="/reference/rest-queries" description="Full field reference including retry, staleTime, and network modes" /%}
350
+
351
+ {% /quick-links %}