@shivaedev/react 0.0.0 → 0.19.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +300 -0
  2. package/LICENSE +21 -0
  3. package/README.md +844 -1
  4. package/dist/actionTrace.d.ts +3 -0
  5. package/dist/actionTrace.d.ts.map +1 -0
  6. package/dist/actionTrace.js +5 -0
  7. package/dist/actionTrace.js.map +1 -0
  8. package/dist/client/QueryView.d.ts +10 -0
  9. package/dist/client/QueryView.d.ts.map +1 -0
  10. package/dist/client/QueryView.js +13 -0
  11. package/dist/client/QueryView.js.map +1 -0
  12. package/dist/client/actionCompletion.d.ts +12 -0
  13. package/dist/client/actionCompletion.d.ts.map +1 -0
  14. package/dist/client/actionCompletion.js +68 -0
  15. package/dist/client/actionCompletion.js.map +1 -0
  16. package/dist/client/authAction.d.ts +15 -0
  17. package/dist/client/authAction.d.ts.map +1 -0
  18. package/dist/client/authAction.js +11 -0
  19. package/dist/client/authAction.js.map +1 -0
  20. package/dist/client/create.d.ts +21 -0
  21. package/dist/client/create.d.ts.map +1 -0
  22. package/dist/client/create.js +35 -0
  23. package/dist/client/create.js.map +1 -0
  24. package/dist/client/editor.d.ts +33 -0
  25. package/dist/client/editor.d.ts.map +1 -0
  26. package/dist/client/editor.js +50 -0
  27. package/dist/client/editor.js.map +1 -0
  28. package/dist/client/mountLive.d.ts +4 -0
  29. package/dist/client/mountLive.d.ts.map +1 -0
  30. package/dist/client/mountLive.js +46 -0
  31. package/dist/client/mountLive.js.map +1 -0
  32. package/dist/client/pendingCommand.d.ts +13 -0
  33. package/dist/client/pendingCommand.d.ts.map +1 -0
  34. package/dist/client/pendingCommand.js +30 -0
  35. package/dist/client/pendingCommand.js.map +1 -0
  36. package/dist/client/resultState.d.ts +32 -0
  37. package/dist/client/resultState.d.ts.map +1 -0
  38. package/dist/client/resultState.js +62 -0
  39. package/dist/client/resultState.js.map +1 -0
  40. package/dist/client/saveState.d.ts +11 -0
  41. package/dist/client/saveState.d.ts.map +1 -0
  42. package/dist/client/saveState.js +37 -0
  43. package/dist/client/saveState.js.map +1 -0
  44. package/dist/client/sessionBoundary.d.ts +15 -0
  45. package/dist/client/sessionBoundary.d.ts.map +1 -0
  46. package/dist/client/sessionBoundary.js +70 -0
  47. package/dist/client/sessionBoundary.js.map +1 -0
  48. package/dist/client/testing.d.ts +28 -0
  49. package/dist/client/testing.d.ts.map +1 -0
  50. package/dist/client/testing.js +108 -0
  51. package/dist/client/testing.js.map +1 -0
  52. package/dist/client/useDraftOwner.d.ts +3 -0
  53. package/dist/client/useDraftOwner.d.ts.map +1 -0
  54. package/dist/client/useDraftOwner.js +36 -0
  55. package/dist/client/useDraftOwner.js.map +1 -0
  56. package/dist/client/useLoaderQuery.d.ts +8 -0
  57. package/dist/client/useLoaderQuery.d.ts.map +1 -0
  58. package/dist/client/useLoaderQuery.js +10 -0
  59. package/dist/client/useLoaderQuery.js.map +1 -0
  60. package/dist/fieldRejection.d.ts +25 -0
  61. package/dist/fieldRejection.d.ts.map +1 -0
  62. package/dist/fieldRejection.js +21 -0
  63. package/dist/fieldRejection.js.map +1 -0
  64. package/dist/internal/NoticeContext.d.ts +7 -0
  65. package/dist/internal/NoticeContext.d.ts.map +1 -0
  66. package/dist/internal/NoticeContext.js +3 -0
  67. package/dist/internal/NoticeContext.js.map +1 -0
  68. package/dist/internal/loadedQuery.d.ts +16 -0
  69. package/dist/internal/loadedQuery.d.ts.map +1 -0
  70. package/dist/internal/loadedQuery.js +11 -0
  71. package/dist/internal/loadedQuery.js.map +1 -0
  72. package/dist/internal/noticeEntry.d.ts +21 -0
  73. package/dist/internal/noticeEntry.d.ts.map +1 -0
  74. package/dist/internal/noticeEntry.js +45 -0
  75. package/dist/internal/noticeEntry.js.map +1 -0
  76. package/dist/internal/noticeStore.d.ts +12 -0
  77. package/dist/internal/noticeStore.d.ts.map +1 -0
  78. package/dist/internal/noticeStore.js +37 -0
  79. package/dist/internal/noticeStore.js.map +1 -0
  80. package/dist/internal/queryHandoff.d.ts +9 -0
  81. package/dist/internal/queryHandoff.d.ts.map +1 -0
  82. package/dist/internal/queryHandoff.js +50 -0
  83. package/dist/internal/queryHandoff.js.map +1 -0
  84. package/dist/internal/sessionRecheck.d.ts +4 -0
  85. package/dist/internal/sessionRecheck.d.ts.map +1 -0
  86. package/dist/internal/sessionRecheck.js +14 -0
  87. package/dist/internal/sessionRecheck.js.map +1 -0
  88. package/dist/internal/useLoaderHandoff.d.ts +4 -0
  89. package/dist/internal/useLoaderHandoff.d.ts.map +1 -0
  90. package/dist/internal/useLoaderHandoff.js +18 -0
  91. package/dist/internal/useLoaderHandoff.js.map +1 -0
  92. package/dist/internal/useNoticePublisher.d.ts +4 -0
  93. package/dist/internal/useNoticePublisher.d.ts.map +1 -0
  94. package/dist/internal/useNoticePublisher.js +8 -0
  95. package/dist/internal/useNoticePublisher.js.map +1 -0
  96. package/dist/internal/useQueryNotice.d.ts +8 -0
  97. package/dist/internal/useQueryNotice.d.ts.map +1 -0
  98. package/dist/internal/useQueryNotice.js +26 -0
  99. package/dist/internal/useQueryNotice.js.map +1 -0
  100. package/dist/internal/useSubmissionCallbacks.d.ts +11 -0
  101. package/dist/internal/useSubmissionCallbacks.d.ts.map +1 -0
  102. package/dist/internal/useSubmissionCallbacks.js +45 -0
  103. package/dist/internal/useSubmissionCallbacks.js.map +1 -0
  104. package/dist/loadQuery.d.ts +6 -0
  105. package/dist/loadQuery.d.ts.map +1 -0
  106. package/dist/loadQuery.js +29 -0
  107. package/dist/loadQuery.js.map +1 -0
  108. package/dist/notices.d.ts +32 -0
  109. package/dist/notices.d.ts.map +1 -0
  110. package/dist/notices.js +34 -0
  111. package/dist/notices.js.map +1 -0
  112. package/dist/queryRoute.d.ts +20 -0
  113. package/dist/queryRoute.d.ts.map +1 -0
  114. package/dist/queryRoute.js +20 -0
  115. package/dist/queryRoute.js.map +1 -0
  116. package/dist/resumeSignal.d.ts +6 -0
  117. package/dist/resumeSignal.d.ts.map +1 -0
  118. package/dist/resumeSignal.js +5 -0
  119. package/dist/resumeSignal.js.map +1 -0
  120. package/dist/submission.d.ts +19 -0
  121. package/dist/submission.d.ts.map +1 -0
  122. package/dist/submission.js +19 -0
  123. package/dist/submission.js.map +1 -0
  124. package/package.json +104 -4
  125. package/src/actionTrace.ts +5 -0
  126. package/src/client/QueryView.ts +27 -0
  127. package/src/client/actionCompletion.ts +82 -0
  128. package/src/client/authAction.ts +22 -0
  129. package/src/client/create.ts +55 -0
  130. package/src/client/editor.ts +89 -0
  131. package/src/client/mountLive.ts +57 -0
  132. package/src/client/pendingCommand.ts +42 -0
  133. package/src/client/resultState.ts +102 -0
  134. package/src/client/saveState.ts +54 -0
  135. package/src/client/sessionBoundary.ts +107 -0
  136. package/src/client/testing.ts +142 -0
  137. package/src/client/useDraftOwner.ts +42 -0
  138. package/src/client/useLoaderQuery.ts +16 -0
  139. package/src/fieldRejection.ts +51 -0
  140. package/src/internal/NoticeContext.ts +4 -0
  141. package/src/internal/loadedQuery.ts +18 -0
  142. package/src/internal/noticeEntry.ts +65 -0
  143. package/src/internal/noticeStore.ts +46 -0
  144. package/src/internal/queryHandoff.ts +60 -0
  145. package/src/internal/sessionRecheck.ts +17 -0
  146. package/src/internal/useLoaderHandoff.ts +20 -0
  147. package/src/internal/useNoticePublisher.ts +10 -0
  148. package/src/internal/useQueryNotice.ts +32 -0
  149. package/src/internal/useSubmissionCallbacks.ts +57 -0
  150. package/src/loadQuery.ts +34 -0
  151. package/src/notices.ts +65 -0
  152. package/src/queryRoute.ts +30 -0
  153. package/src/resumeSignal.ts +13 -0
  154. package/src/submission.ts +39 -0
package/README.md CHANGED
@@ -1,3 +1,846 @@
1
1
  # @shivaedev/react
2
2
 
3
- Placeholder. Real releases start above 0.0.0.
3
+ React screens should not rebuild loading, refresh, save and session rules around every native Effect atom. This package gives queries and actions readable state, joins a record to its editable form, and keeps each session's client state together.
4
+
5
+ ## Why you want this
6
+
7
+ A refresh should not blank a useful screen. A save response should not erase what someone typed while waiting. Signing in as someone else should not inherit the previous person's draft. These hooks and the session boundary handle those transitions around native atoms and forms:
8
+
9
+ ```ts
10
+ const editor = useEditor({
11
+ query: api.get.query({ id }),
12
+ fields: InvoiceLineFields,
13
+ values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
14
+ save: (values) => api.save.run({ id, ...values }),
15
+ runtime: InvoiceLinesClient.runtime,
16
+ });
17
+ ```
18
+
19
+ `api` is the application's native RPC binding, and `InvoiceLineFields` describes its inputs. The editor creates the form when the row arrives. A refresh updates untouched fields and keeps edited ones; a save receives the server's normalized result while keeping edits made during the request. The component renders the form and decides what its loading and error messages say.
20
+
21
+ ## Using it
22
+
23
+ ### How to think about it
24
+
25
+ State belongs to an **atom in a registry**. A query hook reads that atom's native `AsyncResult`; an action hook reads and dispatches a native action atom. Two readers of the same query share its execution. Changing the query atom resets displayed data by default. An application can use `useQuery(atom, { keepPreviousData: true })` to keep the last successful value while a changed payload loads or fails. This retained display belongs to the hook and clears when its registry changes; native atoms still own execution and caching. Editors keep the default reset so a new record starts a new form.
26
+
27
+ `useQuery` and `useAction` turn native results into `data`, `cause`, `pending` and `refreshing`. A query's same-identity refresh always keeps the last successful value, including after a typed failure, so a component can show data and an error together. An action uses the native result's retained success. Keep the atom's identity stable for the same work: declare it outside render or use the native family's identity, such as `AtomRpc.Service.query(...)`.
28
+
29
+ An **editor** joins a query atom, an form field schema and a save Effect. The query provides the row; `values(row)` converts it to input values; the schema decodes those values before the save runs. form owns the draft and the per-field merge. A **create form** uses the same submission rules without a query and starts a fresh form after success.
30
+
31
+ A **session generation** is the string returned by `identify(session)`. `SessionBoundary` owns its client, registry and React subtree. Your auth owner supplies the session and decides when to replace or end it. The boundary automatically mounts live connections on bound contract APIs returned by `connect`, directly or as client properties such as `api`. Use a Platform database host and NDJSON transport; the application needs no subscribe handler or `live()` call. Live changed hints coalesce over 20 ms by default. Set `liveCoalesceWindow` on `SessionBoundary` to a native Duration to choose the window for that session, or `0` for immediate per-hint refreshes. A bound query can override that default with `useQuery(atom, { coalesceWindow: "2 seconds" })`. Changing this option applies to subsequent windows without reconnecting or refetching any query; a window already started keeps its deadline. A refetch in flight runs once more after settling when further hints arrive. Cache policy, RPC declarations and transport stay with native Effect and the application; this package supplies their React state and lifecycle boundary.
32
+
33
+ `useEditor` and `useCreate` opt into persisted input with `persistedDraft: { key }`. Choose a stable key for the record or create request, independent of its server revision. The adapters use form's browser storage and inherit ownership from `SessionBoundary`: reload and query refresh preserve edits, successful submission clears accepted input, and a committed session replacement drops tracked drafts. A suspended replacement that is canceled keeps the visible session's draft and future writes. Later edits made during a save remain persisted. Optional `storage`, `maxBytes` and `maxAgeMs` customize the provider and bounds; defaults are `localStorage`, 256 KiB of UTF-8 JSON and seven days. See [form's persisted input API](https://github.com/ShivaeDev/platform/tree/main/packages/form#persist-input-across-reloads) for direct form hooks and limits.
34
+
35
+ ### 1. Once per application: own the registry
36
+
37
+ For a screen without a session boundary, wrap it in the native provider:
38
+
39
+ ```tsx
40
+ import { RegistryProvider } from "@effect/atom-react";
41
+
42
+ export function App() {
43
+ return <RegistryProvider><Orders /></RegistryProvider>;
44
+ }
45
+ ```
46
+
47
+ `Orders` is the application's feature component. For authenticated screens, use `SessionBoundary` at the authenticated root instead:
48
+
49
+ ```tsx
50
+ import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
51
+ import { makeOrderClient, OrderScreens } from "./orders.tsx";
52
+
53
+ interface Session {
54
+ readonly id: string;
55
+ readonly credentialGeneration: number;
56
+ readonly token: string;
57
+ }
58
+
59
+ export function App({ session, recheck }: {
60
+ readonly session: Session | undefined;
61
+ readonly recheck: () => void;
62
+ }) {
63
+ return (
64
+ <SessionBoundary
65
+ session={session}
66
+ identify={(current) => `${current.id}:${current.credentialGeneration}`}
67
+ connect={(current) => makeOrderClient(current.token)}
68
+ recheck={recheck}
69
+ signedOut={<p>Signed out</p>}
70
+ >
71
+ {(client) => <OrderScreens client={client} />}
72
+ </SessionBoundary>
73
+ );
74
+ }
75
+ ```
76
+
77
+ `makeOrderClient` and `OrderScreens` belong to the application. A changed generation creates a new client and registry, remounts the screens and disposes the outgoing registry. `undefined` renders `signedOut`. Updating the session object with the same generation keeps the existing client: `connect` does not receive a rotated token until the identity changes. Include the credential generation when the client captures its token, as above.
78
+
79
+ For a native shell, pass its resume source when binding the API returned by `connect`:
80
+
81
+ ```ts
82
+ import { bind } from "@shivaedev/contract/bind.ts";
83
+ import type { ResumeSource } from "@shivaedev/react/resumeSignal.ts";
84
+ import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
85
+
86
+ const resume: ResumeSource = (notify) => {
87
+ const stopApp = onAppResume(notify);
88
+ const stopNetwork = onNetworkReturn(notify);
89
+ return () => { stopApp(); stopNetwork(); };
90
+ };
91
+ const connect = () => bind(Notes, Client, { resume });
92
+ ```
93
+
94
+ `Notes` and `Client` are the application's contract and native AtomRpc client. The application implements `onAppResume` and `onNetworkReturn`, each returning its unsubscribe function. `SessionBoundary` mounts the bound connection, and native notifications reopen the subscription and reconcile its tracked queries alongside the browser visibility and online defaults. Disposing the session's registry releases the native listeners. Omitting `resume` keeps the browser defaults.
95
+
96
+ A query that fails with a tagged `Unauthorized` asks `recheck` to run and keeps its retained data. An action does the same once per failed result, retaining its last successful `data` and the typed failure in `cause`; rerendering that result does not repeat the recheck. The auth owner can retry the same session or remove it; the hook does not sign the user out. The boundary also handles React StrictMode's effect replay and preserves component state across hiding and revealing an `<Activity>` subtree, supplying a live registry when revealed.
97
+
98
+ ### Migrate a self-only feature to session keys
99
+
100
+ Before migration, an owner field exists only to give client reads and invalidations a key. The server already chooses the account from `Identity`:
101
+
102
+ ```ts
103
+ const preferences = collection("preferences", Schema.String);
104
+ const Preferences = contract("preferences", {
105
+ queries: [query("self", {
106
+ payload: { ownerId: Schema.String },
107
+ reads: ({ ownerId }) => [preferences.item(ownerId)],
108
+ success: Schema.String,
109
+ })],
110
+ commands: [command("save", {
111
+ payload: { ownerId: Schema.String, title: Schema.String },
112
+ invalidates: ({ ownerId }) => [preferences.item(ownerId)],
113
+ success: Schema.String,
114
+ })],
115
+ }).middleware(Authenticated);
116
+
117
+ const api = bind(Preferences, Client);
118
+ const self = api.self.query({ ownerId: session.userId });
119
+ const save = api.save.run({ ownerId: session.userId, title: "My preferences" });
120
+ ```
121
+
122
+ After migration, remove `ownerId` from both payload schemas and every call site. Add `keyScope: "session"` and keep a logical item key for the self-only record:
123
+
124
+ ```ts
125
+ const preferences = collection("preferences", Schema.String);
126
+ const Preferences = contract("preferences", {
127
+ keyScope: "session",
128
+ queries: [query("self", {
129
+ reads: () => [preferences.item("self")],
130
+ success: Schema.String,
131
+ })],
132
+ commands: [command("save", {
133
+ payload: { title: Schema.String },
134
+ invalidates: () => [preferences.item("self")],
135
+ success: Schema.String,
136
+ })],
137
+ }).middleware(Authenticated);
138
+
139
+ const api = bind(Preferences, Client, { registry });
140
+ const self = api.self.query(undefined);
141
+ const save = api.save.run({ title: "My preferences" });
142
+ ```
143
+
144
+ The examples use `Schema` and `Effect` from `effect`, `contract`, `query`, `command`, `collection` and `bind` from contract's defining modules, and `Authenticated` from `@shivaedev/identity/middleware.ts`. `Client` is the application's native AtomRpc service. In React, obtain `registry` from the second argument to `SessionBoundary.connect`:
145
+
146
+ ```tsx
147
+ import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
148
+
149
+ <SessionBoundary
150
+ session={session}
151
+ identify={(current) => current.generation}
152
+ connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
153
+ recheck={recheck}
154
+ signedOut={<p>Signed out</p>}
155
+ >
156
+ {(api) => <PreferencesScreen api={api} />}
157
+ </SessionBoundary>
158
+ ```
159
+
160
+ `makePreferencesClient(token)` creates the application's native AtomRpc service and configures its transport credentials. Existing one-argument `connect` callbacks continue to work. The boundary allocates the registry before calling `connect`; an unchanged generation keeps its binding and state. Account replacement, sign-out and same-account re-entry dispose the old registry, private query tracking, live registrations and pending refreshes. Choose a generation that changes whenever the auth owner starts a new session.
161
+
162
+ Handlers continue reading the account from request-local `Identity`, imported from `@shivaedev/identity/identity.ts`, before and after migration:
163
+
164
+ ```ts
165
+ Preferences.application.toLayer({
166
+ "preferences.self": () => Effect.flatMap(Identity, ({ id }) => store.read(id)),
167
+ "preferences.save": ({ title }) => Effect.flatMap(Identity, ({ id }) => store.save(id, title)),
168
+ });
169
+ ```
170
+
171
+ `store` is the application's persistence service. No owner or client key scope is accepted from a request. The database app host of `@shivaedev/server` resolves the server key scope from that same request-local identity. `List` and `Item` remain the key shapes; scope is an additional dimension, and the client registry's opaque scope is never sent as an owner id.
172
+
173
+ ### Trace an action across RPCs
174
+
175
+ Editor saves and create submissions automatically run their save Effect in a fresh `ui.submit` trace. For custom actions, wrap the Effect that performs the action with `actionTrace` before giving it to a native atom:
176
+
177
+ ```ts
178
+ import { Effect } from "effect";
179
+ import { actionTrace } from "@shivaedev/react/actionTrace.ts";
180
+
181
+ const save = OrdersClient.runtime.fn((input: OrderInput) =>
182
+ actionTrace(Effect.gen(function* () {
183
+ yield* api.validate.run(input);
184
+ return yield* api.save.run(input);
185
+ }), "ui.saveOrder"),
186
+ );
187
+ ```
188
+
189
+ `OrdersClient`, `OrderInput` and `api` belong to the application. Dispatch `save` with `useAction(save)`. `actionTrace(effect, name?)` preserves the Effect's result, typed error and service requirements; its default span name is `ui.action`. Each execution starts a fresh trace, and RPCs run inside that Effect share it, including across asynchronous boundaries. Native atoms continue to own execution and cancellation. `useAction` also accepts ordinary native action atoms; wrap composite action Effects to group their RPCs.
190
+
191
+ Use native `RpcClient.layerProtocolHttp` with `FetchHttpClient.layer` for the browser RPC protocol. Effect sends the trace in the W3C `traceparent` HTTP header. The `appHost` of `@shivaedev/server` carries it into the RPC span and its SQL, and annotates the RPC span with `request.id`. Enqueued pg-boss jobs retain that span context; worker attempts start separate traces linked back to the sender.
192
+
193
+ ### One supported path for query, command and form feedback
194
+
195
+ Use `useQuery` with `QueryView` for reads, the Effect overload of `useAction` for commands, and `useCreate` or `useEditor` for form submissions. Give these owners notice descriptors and mount one `NoticeHost` inside the session boundary. contract still runs commands and invalidates queries; form still owns submission, fields and drafts. The app supplies wording, Undo payloads and expected versions.
196
+
197
+ Mount the optional `NoticeHost` from `@shivaedev/react/notices.ts` inside `SessionBoundary`, above the screens. The host owns a native atom collection, dismissal, expiry and each notice action's native `AsyncResult`. Supply `render(notice)` to choose markup, wording for action outcomes and labels. Render `notice.action.run` as a button handler and disable that control with `notice.action.pending`; `notice.dismiss` removes the entry. No command requires a host.
198
+
199
+ ```tsx
200
+ import { Cause } from "effect";
201
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
202
+ import { NoticeHost, type Notice } from "@shivaedev/react/notices.ts";
203
+
204
+ function renderNotice(notice: Notice) {
205
+ const action = notice.action;
206
+ return <article role="status">
207
+ <p>{notice.message}</p>
208
+ {action && <button disabled={action.pending} onClick={action.run}>{action.label}</button>}
209
+ {action && AsyncResult.isSuccess(action.result) && <p>Undo completed</p>}
210
+ {action && AsyncResult.isFailure(action.result) && <p>{Cause.pretty(action.result.cause)}</p>}
211
+ <button onClick={notice.dismiss}>Dismiss</button>
212
+ </article>;
213
+ }
214
+
215
+ function SessionScreens({ client }: { readonly client: AppClient }) {
216
+ return <NoticeHost render={renderNotice}><OrderScreens client={client} /></NoticeHost>;
217
+ }
218
+ ```
219
+
220
+ `AppClient` and `OrderScreens` belong to the application; return `SessionScreens` from the session boundary's child function. The renderer receives `id`, `message`, `dismiss` and an optional action with `label`, `run`, `pending` and `result`. Action results retain the native success value or failure cause as `unknown`; narrow domain errors in your renderer to choose their wording.
221
+
222
+ The Effect overload of `useAction` accepts `notices.onSuccess(value, input)` and `notices.onFailure(error, input)`. Each callback returns a descriptor or `undefined`. Independent concurrent completions publish independently, preserving the invocation's input. `useCreate` and `useEditor` accept the same `notices` option around their existing save Effects. Use the descriptors instead of screen-local notice state, expiry timers or result-watching effects:
223
+
224
+ ```ts
225
+ const remove = useAction(OrdersClient.runtime, api.remove.run, {
226
+ concurrent: true,
227
+ notices: {
228
+ onSuccess: (removed, input) => ({
229
+ message: `Removed ${input.title}`,
230
+ action: {
231
+ label: "Undo",
232
+ run: api.restore.run({
233
+ id: input.id,
234
+ title: input.title,
235
+ expectedVersion: removed.version,
236
+ }),
237
+ },
238
+ }),
239
+ onFailure: () => ({ message: "Could not remove this order" }),
240
+ },
241
+ });
242
+ ```
243
+
244
+ `OrdersClient`, `api`, the restore payload and its expected version belong to the application. The host runs the supplied Effect with the operation's native runtime, disables repeated dispatch while it is pending and exposes the outcome on that action. A failed Undo stays on its original notice; it does not become another operation failure notice. Command execution and invalidation remain with contract, and form submission remains with form.
245
+
246
+ For query failures, pass `useQuery(atom, { notice: ({ cause, refreshing }) => descriptor })`. `useEditor` accepts the same callback as `queryNotice` for its query. `refreshing` distinguishes a failure with retained data from an initial load failure. Each settled failed result publishes once per hook; a rerender does not replay it. Omit the callback or return `undefined` for operations that need no notice.
247
+
248
+ The query and form in the same feature use their own feedback options:
249
+
250
+ ```tsx
251
+ const notes = useQuery(api.list.query(), {
252
+ notice: ({ refreshing }) => ({
253
+ message: refreshing ? "Refresh failed" : "Load failed",
254
+ }),
255
+ });
256
+ const save = useCreate({
257
+ fields: TitleFields,
258
+ initialValues: { title: "" },
259
+ create: api.save.run,
260
+ runtime: NotesClient.runtime,
261
+ notices: { onSuccess: (title) => ({ message: `Saved ${title}` }) },
262
+ });
263
+ ```
264
+
265
+ Here `api`, `NotesClient` and `TitleFields` belong to the app. Render `notes` through `QueryView`: its loading renderer shows pending work, its failure renderer offers `notes.refresh` as Retry, and its content renderer holds the form and command controls. Bind fields with form's `useField`, submit with `save.save()` and disable Save with `save.saving`. A tagged field rejection stays beside that input. A held save keeps newer edits when it completes and publishes its success notice before the invalidated query finishes. A failed refresh keeps the last content and the draft while publishing the refresh notice.
266
+
267
+ The [notes feature](https://github.com/ShivaeDev/platform/tree/main/packages/react/src/test-support/feedback-path) composes these owners with the existing in-memory RPC fixture. Its [feedback stories](https://github.com/ShivaeDev/platform/blob/main/packages/react/src/feedbackPath.dom.spec.tsx) cover loading and retry, rejected fields, a held save, concurrent command notices, Undo and its version conflict, and retained content after a refresh failure. The [session story](https://github.com/ShivaeDev/platform/blob/main/packages/react/src/feedbackSession.dom.spec.tsx) replaces a session during a command and Undo: the outgoing registry is disposed, late completion callbacks and outcomes do not publish, and retained notice action and dismiss handlers cannot change the new host. These are React render stories over test RPC handlers, not evidence of a deployed service.
268
+
269
+ A descriptor has `message`, optional `expiresIn` as a native Duration, and optional `action: { label, run }`. Without an expiry it remains until dismissed or the host ends. Dismissal, expiry, host unmount and session replacement remove entries, clear expiry timers and interrupt pending actions. Native uninterruptible Effects may finish, but their old notice cannot affect the new host. Notices survive screen unmount while their host remains mounted.
270
+
271
+ ### 2. Once per feature: native queries and actions
272
+
273
+ The hooks accept native atoms directly. This complete component module shows query and action state without RPC setup:
274
+
275
+ ```tsx
276
+ import { RegistryProvider } from "@effect/atom-react";
277
+ import { QueryView } from "@shivaedev/react/client/QueryView.ts";
278
+ import { useAction, useQuery } from "@shivaedev/react/client/resultState.ts";
279
+ import { Effect, Option } from "effect";
280
+ import * as Atom from "effect/reactivity/Atom";
281
+
282
+ const report = Atom.make(Effect.succeed({ id: 1, title: "Quarterly report" }));
283
+ const rename = Atom.fn<string>()((title) =>
284
+ title.trim() === ""
285
+ ? Effect.fail("Title required" as const)
286
+ : Effect.succeed({ id: 1, title: title.trim() }),
287
+ );
288
+
289
+ function Report() {
290
+ const query = useQuery(report);
291
+ const action = useAction(rename);
292
+ return (
293
+ <section>
294
+ <QueryView
295
+ query={query}
296
+ content={(row) => <h1>{row.title}</h1>}
297
+ loading={(state) => <p>{state.refreshing ? "Refreshing…" : "Loading…"}</p>}
298
+ failure={(error, state) => (
299
+ <p role="alert">{String(error)} <button disabled={state.pending} onClick={state.refresh}>Retry</button></p>
300
+ )}
301
+ />
302
+ <button onClick={query.refresh}>Refresh</button>
303
+ <button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
304
+ Rename
305
+ </button>
306
+ {Option.isSome(action.data) && <output>{action.data.value.title}</output>}
307
+ {Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
308
+ </section>
309
+ );
310
+ }
311
+
312
+ export function App() {
313
+ return <RegistryProvider><Report /></RegistryProvider>;
314
+ }
315
+ ```
316
+
317
+ `QueryView` from `client/QueryView.ts` is stateless presentation over `QueryState<A, E>`, including an editor's `query`. Supply `content(data, query)`, `loading(query)` and `failure(error, query)` renderers. Loading renders while pending, including refreshes, or before the first result; a settled initial rejection renders failure without loading. The failure renderer receives the typed rejection and can call the query's native `refresh` to retry. The component keeps successful content in the same React position through refresh, failure and recovery, preserving mounted inputs. Keep content component types and keys stable as usual.
318
+
319
+ The hook owns retained display data and the native atom owns execution and caching. `QueryView` creates neither. Expected query rejections stay in its failure renderer; defects are thrown to the application's error boundary. It leaves field and command failures with the editor, form or action that owns them. Native `useAtomSuspense` is unchanged and still throws failures.
320
+
321
+ For both queries and actions, a successful `undefined` is `Option.some(undefined)` in `data`, with `pending` and `refreshing` false. A defect or interruption is available as `Option.some(cause)` in `cause`; `isUnauthorized` returns false for those causes and they do not request a session recheck, even when the defect has an `Unauthorized` tag.
322
+
323
+ The example's action returns a value; it does not persist the report or invalidate its query. In an application, use the native RPC query and mutation atoms instead:
324
+
325
+ ```ts
326
+ const notes = useQuery(NotesClient.query("ListNotes", undefined, {
327
+ reactivityKeys: ["notes"],
328
+ }));
329
+ const create = useAction(NotesClient.mutation("CreateNote"));
330
+
331
+ create.dispatch({
332
+ payload: { title: "Morning walk" },
333
+ reactivityKeys: ["notes"],
334
+ });
335
+ ```
336
+
337
+ `NotesClient` is an application's `AtomRpc.Service` with `ListNotes` and `CreateNote` RPCs. The native mutation's reactivity keys refresh the matching query after success.
338
+
339
+ An action's `dispatch(input)` returns `void`. Read the atom's latest result from `data`, `cause` or `result`. With the default `Atom.fn` action, a later dispatch interrupts the earlier work and the shared state reports the later result.
340
+
341
+ ### Completion inside each command invocation
342
+
343
+ Pass the native runtime and the original bound command Effect to `useAction` when the screen needs feedback for each invocation:
344
+
345
+ ```tsx
346
+ const rename = useAction(ReportsClient.runtime, api.rename.run, {
347
+ notices: {
348
+ onSuccess: (saved, input) => ({ message: `Renamed ${input.id} to ${saved.title}` }),
349
+ onFailure: (_error, input) => ({ message: `Could not rename ${input.id}` }),
350
+ },
351
+ });
352
+ ```
353
+
354
+ `ReportsClient` and `api` belong to the application. Input, success, typed failure and runtime requirements are inferred from the command. Use `notices` for the feedback path above. The optional synchronous `onSuccess(value, input)` and `onFailure(error, input)` callbacks are also available for application completion work. They run inside each invocation using the latest committed callbacks and that invocation's input and outcome. Rerendering a result does not replay completion, and an abandoned render cannot replace the committed callback. A callback that throws becomes a native defect; it does not call `onFailure` as a domain rejection.
355
+
356
+ The hook calls the supplied Effect once. A bound contract command still owns invalidation, and completion does not wait for invalidated queries to refetch. Undo payloads, expected versions and presentation text stay in the application.
357
+
358
+ A newer dispatch interrupts the earlier command by default. Interrupted work produces no completion feedback. Pass `{ concurrent: true, onSuccess, onFailure }` to let invocations overlap and report their own outcomes. `result`, `data`, `cause` and `pending` remain the native action atom's aggregate state, including its native result selection during concurrent execution; they do not identify an invocation. Use notice descriptors for per-invocation messages and completion callbacks for application work. Keep the runtime and concurrency policy stable for the same owner.
359
+
360
+ A typed `Unauthorized` failure asks the session owner to recheck once per failed invocation, even with concurrent commands. The hook does not sign out. Session replacement discards outgoing callbacks and rechecks, including when outgoing work cannot be interrupted.
361
+
362
+ ### Better Auth actions
363
+
364
+ `authAction` from `@shivaedev/react/client/authAction.ts` turns a Better Auth client call into a native action atom. Create it with the client, then pass it to `useAction` for the same `pending`, `data`, `cause`, `result` and `dispatch` shape as commands:
365
+
366
+ ```tsx
367
+ const signIn = authAction((credentials: { email: string; password: string }, signal) =>
368
+ authClient.signIn.email(credentials, { signal }),
369
+ );
370
+
371
+ function SignInButton({ email, password }: { email: string; password: string }) {
372
+ const action = useAction(signIn);
373
+ const failure = AsyncResult.error(action.result);
374
+ return (
375
+ <>
376
+ <button disabled={action.pending} onClick={() => action.dispatch({ email, password })}>
377
+ {action.pending ? "Signing in…" : "Sign in"}
378
+ </button>
379
+ {Option.isSome(failure) && <p role="alert">Sign-in failed</p>}
380
+ </>
381
+ );
382
+ }
383
+ ```
384
+
385
+ The example uses `authAction`, `useAction`, `effect/reactivity/AsyncResult` and `effect/Option`, with an application-owned `authClient`. Returned auth errors keep their fields in the typed failure channel. Rejected promises become `AuthActionUnavailable` with the original rejection in `cause`. The application chooses error text and what happens after success.
386
+
387
+ ### Commands with a pending display value
388
+
389
+ Use `usePendingCommand` for a command acting on a query row, such as a list toggle. Give each row a stable action atom with the existing bound command, so the command's declared invalidation keys still drive refetches:
390
+
391
+ ```tsx
392
+ import * as Atom from "effect/reactivity/Atom";
393
+ import { usePendingCommand } from "@shivaedev/react/client/pendingCommand.ts";
394
+
395
+ const toggle = Atom.family((id: number) =>
396
+ ItemsClient.runtime.fn((input: { readonly enabled: boolean }) => api.toggle.run({ id, ...input })),
397
+ );
398
+ const list = api.list.query();
399
+
400
+ function EnabledToggle({ row }: { readonly row: { readonly id: number; readonly enabled: boolean } }) {
401
+ const command = usePendingCommand(toggle(row.id), {
402
+ queries: [list],
403
+ value: row.enabled,
404
+ wanted: (input) => input.enabled,
405
+ });
406
+ return <button aria-pressed={command.value} disabled={command.pending}
407
+ onClick={() => command.dispatch({ enabled: !command.value })} type="button">
408
+ Enabled
409
+ </button>;
410
+ }
411
+ ```
412
+
413
+ Each row needs its own action atom because the native atom owns its current result, including failures. `Atom.family` keeps an independent action atom stable for each row ID. This lets one row fail while another keeps its requested display through the shared list refetch. Do not share one action atom across independently controlled rows.
414
+
415
+ The example assumes a bound `api.toggle.run` accepting `{ id, enabled }`, a bound list query and their native `ItemsClient`. `queries` lists the query atoms whose refetches the display waits for; it does not add invalidations. Include every relevant query and keep the action and query atom identities stable. `value` is the current server value; `wanted(input)` computes the requested display value.
416
+
417
+ `command.value` changes on dispatch and stays at the requested value while the command or any watched query is waiting. `pending` covers that whole interval. After the queries settle, `value` follows the server again, even if it differs from the requested value. A command failure immediately returns to the server value and remains typed in `result` and `cause`, just as with `useAction`. The other action fields describe the native command result, so `result` can already be successful while `pending` still waits for refetches. This binding holds a display value; it does not write to the query cache.
418
+
419
+ ### Route loaders with TanStack Router
420
+
421
+ TanStack Router owns routes, navigation, search validation, loader data and screen components. Spread `queryRoute(buildQuery)` from `@shivaedev/react/queryRoute.ts` into `createRoute` to derive loader dependencies from validated search and load a contract query atom in the router context’s registry. A rejection tagged `NotFound` renders the route’s `notFoundComponent`; other typed failures reach `errorComponent` unchanged. The atom runs through its native RPC runtime and shares its execution with React.
422
+
423
+ This example assumes an application-owned `api.get.query({ id, page })` returning `{ id, title }`, with declared `NotFound` and `NoteMissing` rejections:
424
+
425
+ ```tsx
426
+ import { RegistryContext } from "@effect/atom-react";
427
+ import { createRootRouteWithContext, createRoute, createRouter, RouterProvider, useRouterState } from "@tanstack/react-router";
428
+ import type { ErrorComponentProps } from "@tanstack/react-router";
429
+ import { Effect, Option, Predicate, Schema } from "effect";
430
+ import type { ReactNode } from "react";
431
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
432
+ import { queryRoute } from "@shivaedev/react/queryRoute.ts";
433
+ import { useLoaderQuery } from "@shivaedev/react/client/useLoaderQuery.ts";
434
+ import { api } from "./notes-client.ts";
435
+
436
+ const registry = AtomRegistry.make();
437
+ const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()();
438
+ const Search = Schema.Struct({
439
+ page: Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0)).pipe(
440
+ Schema.catchDecoding(() => Effect.succeed(Option.some(1))),
441
+ Schema.withDecodingDefaultKey(Effect.succeed(1)),
442
+ ),
443
+ });
444
+ const note = createRoute({
445
+ getParentRoute: () => root,
446
+ path: "/notes/$id",
447
+ validateSearch: Schema.toStandardSchemaV1(Search),
448
+ ...queryRoute(({ params, search }: { params: { id: string }; search: typeof Search.Type }) =>
449
+ api.get.query({ id: params.id, page: search.page }),
450
+ ),
451
+ notFoundComponent: () => <h1>Note not found</h1>,
452
+ component: Note,
453
+ errorComponent: Failure,
454
+ });
455
+ const router = createRouter({
456
+ context: { registry },
457
+ routeTree: root.addChildren([note]),
458
+ defaultPendingMs: Infinity,
459
+ InnerWrap: Shell,
460
+ });
461
+
462
+ declare module "@tanstack/react-router" {
463
+ interface Register {
464
+ router: typeof router;
465
+ }
466
+ }
467
+
468
+ function Shell({ children }: { readonly children: ReactNode }) {
469
+ const pending = useRouterState({ select: (state) => state.isLoading });
470
+ return <main>{pending && <p>Loading…</p>}{children}</main>;
471
+ }
472
+ function Note() {
473
+ const query = useLoaderQuery(api.get.query({ ...note.useParams(), ...note.useSearch() }), note.useLoaderData());
474
+ return <h1>{query.data.title}</h1>;
475
+ }
476
+ function Failure({ error }: ErrorComponentProps) {
477
+ return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
478
+ }
479
+ export function App() {
480
+ return <RegistryContext.Provider value={registry}><RouterProvider router={router} /></RegistryContext.Provider>;
481
+ }
482
+ ```
483
+
484
+ `validateSearch` stays native: `Schema.toStandardSchemaV1(Search)` supplies TanStack’s search input and output types. The schema defaults a missing or invalid page to `1`. The query callback names its params and schema output type because TypeScript cannot infer them backward through an object spread. Changing search changes the loader dependencies and queries the new payload. Keep `params`, `validateSearch`, `component`, `errorComponent` and `notFoundComponent` on the route; options after the spread can override its `loaderDeps` or `loader`.
485
+
486
+ For a loader that coordinates more work, use `loadQuery(registry, atom, loaderContext)` from `@shivaedev/react/loadQuery.ts` directly. It reads the atom in the supplied registry and throws typed failures unchanged, including `NotFound`; the loader decides how to handle them.
487
+
488
+ `defaultPendingMs: Infinity` keeps the current route's data visible during navigation; the shell reads TanStack's `isLoading` to display pending state. The new route commits when its loader settles. This is TanStack's pending policy, separate from `useQuery`'s retained display. The loader and the component share one query execution even when the route delays mounting for 650 ms, beyond the registry’s idle window. A DOM screen story proves the first render has the loaded value and the loopback RPC handler runs once.
489
+
490
+ `useLoaderQuery(atom, route.useLoaderData())` subscribes to the same atom and exposes `data` as its success type, with no `Option` or Initial result. Refetches keep the last value visible. For a loader-backed editor, pass the loader value as `useEditor({ ...config, loaded: route.useLoaderData() })`; its form is defined on the first render and `editor.query.data` is the row directly.
491
+
492
+ `SessionBoundary` gives idle atoms the default 400 ms window used by atom-react’s default registry. `loadQuery` also mounts its atom until a component commits its `useQuery` or `useLoaderQuery` subscription, or the loader’s abort signal ends its route ownership. No per-query `timeToLive` is needed. Leaving an unread route releases that mount; a DOM story verifies the atom is gone after the idle window. A new `useLoaderQuery` reader suspends until its active `loadQuery` completes, so revisiting a cached route after invalidation renders the changed data on its first frame. Already mounted readers keep their data during refreshes.
493
+
494
+ `loadQuery` waits through a native query refresh instead of returning its retained success. Typed failures are thrown directly, so the route can inspect `_tag` rather than an Effect fiber wrapper. Defects remain failed Promises. TanStack's loader `abortController` cancels the waiting subscription and rejects with `name: "AbortError"`; other readers of the same query can still complete. The application owns the registry's lifetime and disposes it when its client or session ends. Use the same registry for router context and `RegistryContext.Provider`.
495
+
496
+ Install the optional router peer when using this entry point:
497
+
498
+ ```sh
499
+ pnpm add @tanstack/react-router
500
+ ```
501
+
502
+ ### 3. Once per feature: an editor and a create form
503
+
504
+ The next module assumes `./invoice-lines-client.ts` exports a contract-bound `api` with `get`, `save` and `create`, and its native `InvoiceLinesClient`. `get` returns `{ id, name, quantity }`; both commands return the saved row. The [contract guide](https://github.com/ShivaeDev/platform/blob/main/packages/contract/README.md) describes that binding.
505
+
506
+ ```tsx
507
+ import { useCreate } from "@shivaedev/react/client/create.ts";
508
+ import { useEditor } from "@shivaedev/react/client/editor.ts";
509
+ import { QueryView } from "@shivaedev/react/client/QueryView.ts";
510
+ import { useField } from "@shivaedev/form/client/react.ts";
511
+ import type { Form } from "@shivaedev/form/shape.ts";
512
+ import { Option, Schema } from "effect";
513
+ import { api, InvoiceLinesClient } from "./invoice-lines-client.ts";
514
+
515
+ const InvoiceLineFields = Schema.Struct({
516
+ name: Schema.String,
517
+ quantity: Schema.NumberFromString,
518
+ });
519
+ type Fields = typeof InvoiceLineFields.fields;
520
+
521
+ function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
522
+ const name = useField(form, "name");
523
+ const quantity = useField(form, "quantity");
524
+ return (
525
+ <>
526
+ <label>
527
+ Name
528
+ <input name="name" value={name.value} onBlur={name.onBlur}
529
+ onChange={(event) => name.onChange(event.target.value)} />
530
+ </label>
531
+ {name.error && <p role="alert">{name.error}</p>}
532
+ <label>
533
+ Quantity
534
+ <input name="quantity" value={quantity.value} onBlur={quantity.onBlur}
535
+ onChange={(event) => quantity.onChange(event.target.value)} />
536
+ </label>
537
+ {quantity.error && <p role="alert">{quantity.error}</p>}
538
+ </>
539
+ );
540
+ }
541
+
542
+ export function InvoiceLineEditor({ id }: { readonly id: number }) {
543
+ const editor = useEditor({
544
+ query: api.get.query({ id }),
545
+ fields: InvoiceLineFields,
546
+ values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
547
+ save: (values) => api.save.run({ id, ...values }),
548
+ runtime: InvoiceLinesClient.runtime,
549
+ });
550
+ return (
551
+ <QueryView
552
+ query={editor.query}
553
+ loading={(query) => <p>{query.refreshing ? "Refreshing…" : "Loading…"}</p>}
554
+ failure={(_error, query) => (
555
+ <p role="alert">Could not load <button disabled={query.pending} onClick={query.refresh}>Retry</button></p>
556
+ )}
557
+ content={() => editor.form && (
558
+ <form onSubmit={(event) => { event.preventDefault(); editor.save(); }}>
559
+ <Inputs form={editor.form} />
560
+ {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
561
+ <button type="submit" disabled={editor.saving}>Save</button>
562
+ <button type="button" onClick={editor.query.refresh}>Refresh</button>
563
+ <button type="button" onClick={editor.revert}>Revert</button>
564
+ <p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
565
+ </form>
566
+ )}
567
+ />
568
+ );
569
+ }
570
+
571
+ export function InvoiceLineCreate() {
572
+ const create = useCreate({
573
+ fields: InvoiceLineFields,
574
+ initialValues: { name: "", quantity: "" },
575
+ create: api.create.run,
576
+ runtime: InvoiceLinesClient.runtime,
577
+ });
578
+ return (
579
+ <form onSubmit={(event) => { event.preventDefault(); create.save(); }}>
580
+ <Inputs form={create.form} />
581
+ {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
582
+ <button type="submit" disabled={create.saving}>Create</button>
583
+ {Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
584
+ </form>
585
+ );
586
+ }
587
+ ```
588
+
589
+ The form holds strings for both inputs. Its schema decodes `quantity` to a number before `save` or `create` receives it. `values(row)` converts server rows back to input values. The runtime supplies the services needed by schema decoding and the command Effect.
590
+
591
+ The editor's form is absent until its first row arrives. A different query atom creates a new form, so another record starts without the previous draft. Refresh results adopt untouched fields and leave edited fields alone. Saved rows supply server normalization while edits made during the save remain dirty. A refresh failure keeps the draft, and `query.refresh()` retries.
592
+
593
+ `save()` ignores a second call while saving. Submitting through form's own `useSubmit(form).run()` or setting `form.submit` also settles the editor or create hook: an editor receives the saved row, and a create records its result and resets.
594
+
595
+ A successful create replaces its form with one at `initialValues`, keeping fields edited after submission began, including while an async schema decoded it. The next entry starts without the previous entry's displayed field errors. `created` holds the successful result as an `Option`.
596
+
597
+ ### One-shot create and edit
598
+
599
+ Use `useCreate` when the app already has the opening values and needs one submission. Its `create` callback accepts any submission Effect, including an update command; `created` holds that Effect's successful result. Creation above and the query-free edit below use the same form lifecycle. No upsert or new form hook is needed: creation and update remain separate domain commands with explicit payloads.
600
+
601
+ For this versioned example, the app's `save` command requires `{ id, expectedVersion, name, quantity }` and returns the row with its new `version`. It rejects a stale version with a tagged `Conflict` error. Reuse `InvoiceLineFields` and `Inputs` from above:
602
+
603
+ ```tsx
604
+ import { Option } from "effect";
605
+ import { useEffect, useState } from "react";
606
+ import { useCreate } from "@shivaedev/react/client/create.ts";
607
+
608
+ type VersionedLine = {
609
+ readonly id: number;
610
+ readonly version: number;
611
+ readonly name: string;
612
+ readonly quantity: number;
613
+ };
614
+
615
+ export function OneShotEdit({ opening, onSaved }: {
616
+ readonly opening: VersionedLine;
617
+ readonly onSaved: (line: VersionedLine) => void;
618
+ }) {
619
+ const [original] = useState(opening);
620
+ const edit = useCreate({
621
+ fields: InvoiceLineFields,
622
+ initialValues: { name: original.name, quantity: String(original.quantity) },
623
+ create: (values) => api.save.run({
624
+ id: original.id,
625
+ expectedVersion: original.version,
626
+ ...values,
627
+ }),
628
+ runtime: InvoiceLinesClient.runtime,
629
+ });
630
+ useEffect(() => {
631
+ if (Option.isSome(edit.created)) onSaved(edit.created.value);
632
+ }, [edit.created, onSaved]);
633
+ return (
634
+ <form onSubmit={(event) => { event.preventDefault(); edit.save(); }}>
635
+ <Inputs form={edit.form} />
636
+ {Option.isSome(edit.failure) && <p role="alert">
637
+ {edit.failure.value._tag === "Conflict"
638
+ ? "This line changed. Reopen it to use the latest version."
639
+ : "Could not save"}
640
+ </p>}
641
+ <button type="submit" disabled={edit.saving}>Save</button>
642
+ </form>
643
+ );
644
+ }
645
+ ```
646
+
647
+ The app captures the opening record once. Pass a stable `onSaved` callback to close the dialog, navigate or record a completion message; mount a new form for another opening. Domain data, including expected versions and any Undo payload, stays in the app. A conflict exposes `failure` and keeps the input. Field rejections stay on the submitted form until corrected; stale-rejection matching belongs to form. Success uses `useCreate`'s normal reset to opening values while preserving input changed during submission, so `onSaved` owns what happens next rather than turning this into a continuing editor.
648
+
649
+ Use `useEditor` for a continuing editor that subscribes to a record, merges refreshes and receives saved rows as its new baseline. Its `loaded` option supplies the initial row, usually from a route loader; it still requires `query` because the query owns identity, refresh and invalidation. Supplying `loaded` does not make an editor query-free. In either pattern contract owns the command Effect and invalidation, form owns submission and field errors, and react exposes query state where a query exists.
650
+
651
+ ### Hook configuration between renders
652
+
653
+ `useCreate` calls the latest committed render's `create`, and `useEditor` calls its latest `save`. Both hooks use the latest `rejectField` when a save fails, including when the mapper is removed to restore automatic field mapping. Inline callbacks may close over changing props or state without replacing the form or losing edits.
654
+
655
+ Changing the `fields` schema or `runtime` by reference replaces either hook's form and discards its in-memory draft and submission state. A changed editor `query` also starts a new form. Keep schemas, runtimes and query atoms outside components or memoize them; constructing a schema on every render continually replaces the form.
656
+
657
+ `initialValues` (create), `values` (editor) and `persistedDraft` are setup options, captured when the hook first creates a form and again when `fields`, `runtime` or editor `query` changes. Successful creates reuse the captured initial values and persistence options for the next entry. Changing setup options alone does not replace an existing form; remount the component with a different React `key` to apply them. An editor's `loaded` value remains a fallback for query data, as with `useLoaderQuery`.
658
+
659
+ ### Field rejections and other failures
660
+
661
+ A tagged error with string `_tag` and `message`, and a `field` naming a form field, becomes that field's message by default. Other save failures appear in `failure`. Use `rejectField` when the server uses a different shape or field name:
662
+
663
+ ```ts
664
+ const TitleFields = Schema.Struct({ title: Schema.String });
665
+
666
+ const editor = useEditor({
667
+ query,
668
+ fields: TitleFields,
669
+ values: (row) => ({ title: row.title }),
670
+ save,
671
+ runtime,
672
+ rejectField: (error) => error._tag === "SubtitleRejected"
673
+ ? { field: "title", message: error.message }
674
+ : undefined,
675
+ });
676
+ ```
677
+
678
+ Here `save` has a tagged `SubtitleRejected` failure referring to the server's `subtitle`, while the form calls the input `title`. If a tagged error's field type includes names outside the form, `rejectField` is required at compile time. This includes a broad `string` field or an optional field whose type is broader than the form's names. The mapper can return `undefined` to leave an error as a non-field failure.
679
+
680
+ ### Resume and refresh policy
681
+
682
+ Keep the signal and query identities stable:
683
+
684
+ ```ts
685
+ import { resumeSignal } from "@shivaedev/react/resumeSignal.ts";
686
+ import { Effect } from "effect";
687
+ import * as Atom from "effect/reactivity/Atom";
688
+
689
+ const resume = resumeSignal({ window });
690
+ const query = Atom.makeRefreshOnSignal(resume)(Atom.make(Effect.succeed("ready")));
691
+ ```
692
+
693
+ `resumeSignal` changes on visible `visibilitychange` and visible `online` events. Hidden browser events do not refresh the query. An optional `native` source subscribes a callback and returns its unsubscribe function; its callback also changes the signal. Registry disposal removes the listeners. This module uses the shared [contract resume implementation](https://github.com/ShivaeDev/platform/blob/main/packages/contract/src/resume.ts).
694
+
695
+ Use native stale-while-revalidate policy when a resume should refresh only stale data:
696
+
697
+ ```ts
698
+ const query = Atom.swr(atom, {
699
+ focusSignal: resume,
700
+ revalidateOnFocus: true,
701
+ staleTime: "1 hour",
702
+ });
703
+ ```
704
+
705
+ `atom` is the underlying native query. `Atom.swr` owns the staleness decision; the signal only reports that the application resumed.
706
+
707
+ ### API
708
+
709
+ `useQuery(atom)` and `useAction(atom)` return these shared fields:
710
+
711
+ | Field | Meaning |
712
+ | --- | --- |
713
+ | `result` | Native `AsyncResult<A, E>`. |
714
+ | `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
715
+ | `cause` | `Option<Cause<E>>` from a failed result. |
716
+ | `pending` | The native result is waiting. |
717
+ | `refreshing` | The result is waiting and successful data is available. |
718
+
719
+ `useQuery(atom, options?)` adds `refresh(): void`; `useAction` adds `dispatch(input): void`. `QueryOptions.coalesceWindow` accepts a native Duration for live changed hints on contract-bound queries; it belongs to the query atom in its registry, so readers sharing that atom share the window. Omitting it uses the session default. `QueryOptions.keepPreviousData` defaults to `false` and controls retention across query atom changes. Same-identity refresh retention is always enabled. `result` and `cause` describe the current native query even when `data` displays a previous atom's success. Success, input and typed failure types are inferred from the supplied atom. The Effect overload is `useAction(runtime, run, options?)`, where `ActionOptions` supplies `concurrent`, `onSuccess(value, input)` and `onFailure(error, input)`; its shared state also includes the native runtime's typed failure.
720
+
721
+ Import the defining `.ts` module, such as `@shivaedev/react/client/editor.ts`. There is no root import. `useSessionRecheck()` from `client/sessionBoundary.ts` reads the boundary's `recheck` callback.
722
+
723
+ Prefer `useEditor` and `useCreate` when binding a form to a command. The lower-level modules expose the same composition pieces: `submission` from `submission.ts` makes a form and its submission snapshot reader, `rejecting` from `fieldRejection.ts` maps save failures through a submitter, and `useSaveState` from `client/saveState.ts` observes submission and invokes a success callback.
724
+
725
+ ### Render tests in happy-dom
726
+
727
+ Use `@shivaedev/react/client/testing.ts` with Vitest's `happy-dom` environment. Install contract for the example's query and command declarations, and the testing peers and environment in your app's dev dependencies:
728
+
729
+ ```sh
730
+ pnpm add @shivaedev/contract
731
+ pnpm add -D @shivaedev/test @effect/vitest@4.0.5 vitest happy-dom react-dom@19.3.0 @types/react-dom
732
+ ```
733
+
734
+ Configure Vitest with `test: { environment: "happy-dom" }`, or select that environment for your DOM test project. This complete `notes.dom.test.tsx` renders a component that runs a declared query and command against native RPC test handlers:
735
+
736
+ ```tsx
737
+ import { Effect, Option, Ref, Schema } from "effect";
738
+ import * as AtomRpc from "effect/reactivity/AtomRpc";
739
+ import { RpcTest } from "effect/rpc";
740
+ import { act, createElement } from "react";
741
+ import { expect } from "vitest";
742
+ import { bind } from "@shivaedev/contract/bind.ts";
743
+ import { contract } from "@shivaedev/contract/contract.ts";
744
+ import { collection } from "@shivaedev/contract/keys.ts";
745
+ import { command, query } from "@shivaedev/contract/operation.ts";
746
+ import { useAction, useQuery } from "@shivaedev/react/client/resultState.ts";
747
+ import { eventually, expectNoReactWarnings, render } from "@shivaedev/react/client/testing.ts";
748
+ import { it } from "@shivaedev/test/it.ts";
749
+
750
+ Object.assign(globalThis, { "IS_REACT_ACT_ENVIRONMENT": true });
751
+ expectNoReactWarnings();
752
+
753
+ const notes = collection("notes", Schema.Number);
754
+ const List = query("list", { reads: () => [notes.list], success: Schema.Array(Schema.String) });
755
+ const Add = command("add", { invalidates: () => [notes.list], payload: { title: Schema.String } });
756
+ const Notes = contract("notes", { commands: [Add], queries: [List] });
757
+
758
+ const handlers = Notes.toLayer(
759
+ Effect.gen(function* () {
760
+ const stored = yield* Ref.make<readonly string[]>([]);
761
+ return Notes.of({
762
+ "notes.add": ({ title }) => Ref.update(stored, (all) => [...all, title]),
763
+ "notes.list": () => Ref.get(stored).pipe(Effect.delay("20 millis")),
764
+ });
765
+ }),
766
+ );
767
+ class Client extends AtomRpc.Service<Client>()("test/Notes", {
768
+ group: Notes,
769
+ makeEffect: RpcTest.makeClient(Notes, { flatten: true }),
770
+ protocol: handlers,
771
+ }) {}
772
+ const api = bind(Notes, Client);
773
+ const add = Client.runtime.fn(api.add.run);
774
+ function NotesView() {
775
+ const list = useQuery(api.list.query());
776
+ const action = useAction(add);
777
+ return createElement(
778
+ "section",
779
+ null,
780
+ createElement("output", null, Option.getOrElse(list.data, () => []).join(", ")),
781
+ createElement(
782
+ "button",
783
+ {
784
+ disabled: list.pending || action.pending,
785
+ onClick: () => action.dispatch({ title: "Morning walk" }),
786
+ type: "button",
787
+ },
788
+ "Add",
789
+ ),
790
+ );
791
+ }
792
+
793
+ it.live("renders a query and refetches it after a command through a test RPC layer", function* () {
794
+ const view = yield* Effect.promise(() => render(createElement(NotesView)));
795
+ try {
796
+ yield* eventually(() => expect(view.container.querySelector("button")?.disabled).toBe(false));
797
+ expect(view.container.querySelector("output")?.textContent).toBe("");
798
+ yield* Effect.promise(() => act(async () => view.container.querySelector("button")?.click()));
799
+ yield* eventually(() => {
800
+ expect(view.container.querySelector("output")?.textContent).toBe("Morning walk");
801
+ expect(view.container.querySelector("button")?.disabled).toBe(false);
802
+ });
803
+ } finally {
804
+ yield* Effect.promise(view.unmount);
805
+ }
806
+ });
807
+ ```
808
+
809
+ `render(node, options?)` returns the attached `container`, its fresh native `registry`, `render(nextNode)` to update the same root and registry, and `unmount()` to release the root, registry and container. The options are React's own `RootOptions`, passed to `createRoot`, including `onCaughtError`, `onUncaughtError` and `onRecoverableError`. Always unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` returns an Effect: yield it directly in a story or Effect test, keeping the expectation in the spec. It retries for up to three seconds in live time, allowing pending responses to settle inside `act` between attempts without advancing the story’s TestClock. A timeout fails with an Error whose message includes the elapsed milliseconds and last assertion message, and whose `cause` is that assertion error. In an async test, use `await Effect.runPromise(eventually(assert))`. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
810
+
811
+ For a boundary fallback test, keep the warnings guard and declare the caught error inside the test. `expectCaughtError(predicate)` returns an `onCaughtError` handler that accepts matching errors without logging them. The guard fails if the handler receives any other error or the expected error never arrives. Other console errors and React warnings still fail the guard.
812
+
813
+ ```tsx
814
+ const warnings = expectNoReactWarnings();
815
+
816
+ screenStories.it("shows the route failure", [], async () => {
817
+ const onCaughtError = warnings.expectCaughtError((error) => error instanceof RouteFailure);
818
+ const view = await render(<RouteFailureScreen />, { onCaughtError });
819
+ try {
820
+ expect(view.container.querySelector('[role="alert"]')?.textContent).toBe("Route unavailable");
821
+ } finally {
822
+ await view.unmount();
823
+ }
824
+ });
825
+ ```
826
+
827
+ The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs.
828
+
829
+ Story kits can acquire `captureReactWarnings` from `client/testing.ts` in their `create` effect with `const warnings = yield* captureReactWarnings`. The scope owns the capture and restores `console.error` when it closes, including after a failed story. Pass `warnings.onCaughtError` to `render` to capture boundary errors, or `warnings.expectCaughtError(predicate)` to exclude a declared error. Both handlers silence caught errors; the capture also silences and records `console.error` calls. `warnings.read()` returns `{ consoleErrors, caughtErrors }`, with each console call's arguments and the unexpected caught errors. It asserts nothing, leaving the story spec to state its expectation. The Vitest guard uses the same capture mechanism and additionally checks that every declared error occurred.
830
+
831
+ ### Install, setup and limits
832
+
833
+ ```sh
834
+ pnpm add @shivaedev/react @effect/atom-react@4.0.5 effect@4.0.5 react@19.3.0
835
+ ```
836
+
837
+ For editor, create or lower-level form modules, also install the optional form peer:
838
+
839
+ ```sh
840
+ pnpm add @shivaedev/form
841
+ ```
842
+
843
+ Query, action, session and resume modules do not require form. The package needs Node 24 or newer.
844
+
845
+ - Query identity, TTL, invalidation and action concurrency remain native atom policies. Put the registry at the lifetime boundary those policies need.
846
+ - The package has no SSR registry/hydration adapter, offline write queue or optimistic reconciliation policy.