@shivaedev/effect-react 0.2.1 → 0.2.2

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 (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +452 -55
  3. package/dist/client/create.d.ts +17 -0
  4. package/dist/client/create.d.ts.map +1 -0
  5. package/dist/{create.js → client/create.js} +9 -9
  6. package/dist/client/create.js.map +1 -0
  7. package/dist/client/editor.d.ts +20 -0
  8. package/dist/client/editor.d.ts.map +1 -0
  9. package/dist/{editor.js → client/editor.js} +5 -5
  10. package/dist/client/editor.js.map +1 -0
  11. package/dist/client/resultState.d.ts +23 -0
  12. package/dist/client/resultState.d.ts.map +1 -0
  13. package/dist/client/resultState.js +48 -0
  14. package/dist/client/resultState.js.map +1 -0
  15. package/dist/{save-state.d.ts → client/saveState.d.ts} +2 -2
  16. package/dist/client/saveState.d.ts.map +1 -0
  17. package/dist/{save-state.js → client/saveState.js} +5 -5
  18. package/dist/client/saveState.js.map +1 -0
  19. package/dist/client/sessionBoundary.d.ts +12 -0
  20. package/dist/client/sessionBoundary.d.ts.map +1 -0
  21. package/dist/{session-boundary.js → client/sessionBoundary.js} +18 -12
  22. package/dist/client/sessionBoundary.js.map +1 -0
  23. package/dist/client/testing.d.ts +12 -0
  24. package/dist/client/testing.d.ts.map +1 -0
  25. package/dist/client/testing.js +66 -0
  26. package/dist/client/testing.js.map +1 -0
  27. package/dist/field-rejection.d.ts.map +1 -1
  28. package/dist/field-rejection.js +3 -1
  29. package/dist/field-rejection.js.map +1 -1
  30. package/dist/loadQuery.d.ts +6 -0
  31. package/dist/loadQuery.d.ts.map +1 -0
  32. package/dist/loadQuery.js +21 -0
  33. package/dist/loadQuery.js.map +1 -0
  34. package/dist/submission.d.ts.map +1 -1
  35. package/docs/north-star.md +45 -0
  36. package/docs/roadmap.md +28 -0
  37. package/package.json +122 -16
  38. package/src/client/create.ts +41 -0
  39. package/src/client/editor.ts +56 -0
  40. package/src/client/resultState.ts +73 -0
  41. package/src/{save-state.ts → client/saveState.ts} +11 -10
  42. package/src/{session-boundary.ts → client/sessionBoundary.ts} +28 -32
  43. package/src/client/testing.ts +68 -0
  44. package/src/field-rejection.ts +3 -1
  45. package/src/loadQuery.ts +27 -0
  46. package/dist/create.d.ts +0 -17
  47. package/dist/create.d.ts.map +0 -1
  48. package/dist/create.js.map +0 -1
  49. package/dist/editor.d.ts +0 -20
  50. package/dist/editor.d.ts.map +0 -1
  51. package/dist/editor.js.map +0 -1
  52. package/dist/result-state.d.ts +0 -20
  53. package/dist/result-state.d.ts.map +0 -1
  54. package/dist/result-state.js +0 -36
  55. package/dist/result-state.js.map +0 -1
  56. package/dist/save-state.d.ts.map +0 -1
  57. package/dist/save-state.js.map +0 -1
  58. package/dist/session-boundary.d.ts +0 -12
  59. package/dist/session-boundary.d.ts.map +0 -1
  60. package/dist/session-boundary.js.map +0 -1
  61. package/src/create.ts +0 -43
  62. package/src/editor.ts +0 -58
  63. package/src/result-state.ts +0 -58
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 - 2026-10-12
4
+
5
+ ### Added
6
+
7
+ - Publish `@shivaedev/effect-react/testing.ts` with `render`, `Rendered`, `eventually` and `expectNoReactWarnings` for happy-dom render tests using native atom registries and test RPC layers. `render` exposes its registry, supports rerendering and releases its root, registry and container on unmount. React DOM and Vitest are optional peers needed by the testing entry.
8
+ - Add opt-in `useQuery(atom, { keepPreviousData: true })` to retain the last successful value while a changed query payload loads or fails. Query identity changes still reset displayed data by default, and registry changes clear it even with retention enabled. Native results, typed failures and interruption remain visible.
9
+ - `@shivaedev/effect-react/router.ts` exports `loadQuery(registry, atom, loaderContext)` for TanStack Router loaders. It reads contract query atoms in the application's shared registry, throws typed failures as their original tagged errors, and releases an aborted loader's subscription. TanStack Router is an optional peer.
10
+
11
+ ### Changed
12
+
13
+ - Document the package's purpose, native atom mental model, query/action usage, session ownership and editor/create workflows; publish the north star and package roadmap with the README.
14
+ - Build package JavaScript, declarations and source maps with TypeScript 7.
15
+ - The reference PostgreSQL order handlers use the shared server live helper and support composing PostgreSQL change delivery. The reference fixture proves commits from two HTTP server processes and a pg-boss worker, rollback silence, and live resync after listener loss.
16
+
17
+ ### Fixed
18
+
19
+ - Keep the last successful query value during same-identity refreshes, including live invalidation, and after failed refreshes even when the native result has no retained success. `refreshing` reports pending work alongside displayed data.
20
+
3
21
  ## 0.2.1 - 2026-10-05
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -1,88 +1,485 @@
1
1
  # @shivaedev/effect-react
2
2
 
3
- Small React hooks and a session boundary for native Effect atoms. Query data survives refreshes and failures when the underlying `AsyncResult` contains a previous success. Actions expose ordinary RPC success values and typed failures as native atom state.
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
4
 
5
- This package targets Effect and `@effect/atom-react` **4.0.0-rc.112** and React 19. Native atoms own identity, caching, invalidation, serialization and cancellation. There is no second query cache or RPC contract language.
5
+ ## Why you want this
6
6
 
7
- ## Native RPC usage
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
8
 
9
- Given an `Api` service constructed with native `AtomRpc.Service`, and RPCs `Orders` and `RenameOrder`:
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 effect-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. effect-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. Cache policy, RPC declarations and transport stay with native Effect and the application; this package supplies their React state and lifecycle boundary.
32
+
33
+ ### 1. Once per application: own the registry
34
+
35
+ For a screen without a session boundary, wrap it in the native provider:
36
+
37
+ ```tsx
38
+ import { RegistryProvider } from "@effect/atom-react";
39
+
40
+ export function App() {
41
+ return <RegistryProvider><Orders /></RegistryProvider>;
42
+ }
43
+ ```
44
+
45
+ `Orders` is the application's feature component. For authenticated screens, use `SessionBoundary` at the authenticated root instead:
46
+
47
+ ```tsx
48
+ import { SessionBoundary } from "@shivaedev/effect-react/session-boundary.ts";
49
+ import { makeOrderClient, OrderScreens } from "./orders.tsx";
50
+
51
+ interface Session {
52
+ readonly id: string;
53
+ readonly credentialGeneration: number;
54
+ readonly token: string;
55
+ }
56
+
57
+ export function App({ session, recheck }: {
58
+ readonly session: Session | undefined;
59
+ readonly recheck: () => void;
60
+ }) {
61
+ return (
62
+ <SessionBoundary
63
+ session={session}
64
+ identify={(current) => `${current.id}:${current.credentialGeneration}`}
65
+ connect={(current) => makeOrderClient(current.token)}
66
+ recheck={recheck}
67
+ signedOut={<p>Signed out</p>}
68
+ >
69
+ {(client) => <OrderScreens client={client} />}
70
+ </SessionBoundary>
71
+ );
72
+ }
73
+ ```
74
+
75
+ `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.
76
+
77
+ A query that fails with a tagged `Unauthorized` asks `recheck` to run and keeps its retained data. 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.
78
+
79
+ ### 2. Once per feature: native queries and actions
80
+
81
+ The hooks accept native atoms directly. This complete component module shows query and action state without RPC setup:
10
82
 
11
83
  ```tsx
12
- import { RegistryProvider } from "@effect/atom-react"
13
- import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts"
14
- import { Option } from "effect"
15
-
16
- function Orders() {
17
- const orders = useQuery(Api.query("Orders", {}, {
18
- reactivityKeys: ["orders"],
19
- timeToLive: "1 minute",
20
- }))
21
- const rename = useAction(Api.mutation("RenameOrder"))
22
-
23
- function save(id: string, title: string) {
24
- rename.dispatch({
25
- payload: { id, title },
26
- reactivityKeys: ["orders"],
27
- })
84
+ import { RegistryProvider } from "@effect/atom-react";
85
+ import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts";
86
+ import { Effect, Option } from "effect";
87
+ import * as Atom from "effect/unstable/reactivity/Atom";
88
+
89
+ const report = Atom.make(Effect.succeed({ id: 1, title: "Quarterly report" }));
90
+ const rename = Atom.fn<string>()((title) =>
91
+ title.trim() === ""
92
+ ? Effect.fail("Title required" as const)
93
+ : Effect.succeed({ id: 1, title: title.trim() }),
94
+ );
95
+
96
+ function Report() {
97
+ const query = useQuery(report);
98
+ const action = useAction(rename);
99
+ return (
100
+ <section>
101
+ {Option.match(query.data, {
102
+ onNone: () => <p>Loading…</p>,
103
+ onSome: (row) => <h1>{row.title}</h1>,
104
+ })}
105
+ {query.refreshing && <p>Refreshing…</p>}
106
+ {Option.isSome(query.cause) && <p role="alert">Could not load</p>}
107
+ <button onClick={query.refresh}>Refresh</button>
108
+ <button disabled={action.pending} onClick={() => action.dispatch("Annual report")}>
109
+ Rename
110
+ </button>
111
+ {Option.isSome(action.data) && <output>{action.data.value.title}</output>}
112
+ {Option.isSome(action.cause) && <p role="alert">Could not rename</p>}
113
+ </section>
114
+ );
115
+ }
116
+
117
+ export function App() {
118
+ return <RegistryProvider><Report /></RegistryProvider>;
119
+ }
120
+ ```
121
+
122
+ 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:
123
+
124
+ ```ts
125
+ const notes = useQuery(NotesClient.query("ListNotes", undefined, {
126
+ reactivityKeys: ["notes"],
127
+ }));
128
+ const create = useAction(NotesClient.mutation("CreateNote"));
129
+
130
+ create.dispatch({
131
+ payload: { title: "Morning walk" },
132
+ reactivityKeys: ["notes"],
133
+ });
134
+ ```
135
+
136
+ `NotesClient` is an application's `AtomRpc.Service` with `ListNotes` and `CreateNote` RPCs. The native mutation's reactivity keys refresh the matching query after success. The package's rendered native RPC fixture exercises this shape through a real repository, including a rejected save that preserves the list.
137
+
138
+ 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. Disable duplicate submissions or give independent work distinct native action atoms. When one caller needs its own success or failure, run the actual RPC Effect through its runtime, as the editor's `save` does; shared atom state cannot identify one invocation's completion.
139
+
140
+ ### Route loaders with TanStack Router
141
+
142
+ TanStack Router owns routes, navigation, loader data and error components. `@shivaedev/effect-react/router.ts` supplies `loadQuery(registry, atom, loaderContext)` to read a contract query atom in the same registry as React. The atom runs through its native RPC runtime; the loader does not create another runtime or query cache.
143
+
144
+ This example assumes an application-owned `api.get.query({ id })` returning `{ id, title }`, with a declared `NoteMissing` rejection:
145
+
146
+ ```tsx
147
+ import { RegistryContext } from "@effect/atom-react";
148
+ import { createRootRouteWithContext, createRoute, createRouter, Outlet, RouterProvider, useRouterState } from "@tanstack/react-router";
149
+ import type { ErrorComponentProps } from "@tanstack/react-router";
150
+ import { Predicate } from "effect";
151
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
152
+ import { loadQuery } from "@shivaedev/effect-react/router.ts";
153
+ import { api } from "./notes-client.ts";
154
+
155
+ const registry = AtomRegistry.make();
156
+ const root = createRootRouteWithContext<{ registry: AtomRegistry.AtomRegistry }>()({
157
+ component: Shell,
158
+ });
159
+ const note = createRoute({
160
+ getParentRoute: () => root,
161
+ path: "/notes/$id",
162
+ loader: (context) => loadQuery(context.context.registry, api.get.query(context.params), context),
163
+ component: Note,
164
+ errorComponent: Failure,
165
+ });
166
+ const router = createRouter({
167
+ context: { registry },
168
+ routeTree: root.addChildren([note]),
169
+ defaultPendingMs: Infinity,
170
+ });
171
+
172
+ declare module "@tanstack/react-router" {
173
+ interface Register {
174
+ router: typeof router;
28
175
  }
176
+ }
177
+
178
+ function Shell() {
179
+ const pending = useRouterState({ select: (state) => state.isLoading });
180
+ return <main>{pending && <p>Loading…</p>}<Outlet /></main>;
181
+ }
182
+ function Note() {
183
+ const row = note.useLoaderData();
184
+ return <h1>{row.title}</h1>;
185
+ }
186
+ function Failure({ error }: ErrorComponentProps) {
187
+ return <p role="alert">{Predicate.isTagged(error, "NoteMissing") ? "Note missing" : "Could not load"}</p>;
188
+ }
189
+ export function App() {
190
+ return <RegistryContext.Provider value={registry}><RouterProvider router={router} /></RegistryContext.Provider>;
191
+ }
192
+ ```
193
+
194
+ `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 render fixture proves navigation between two note payloads, shared loader/component query execution, and a declared RPC failure reaching the error component as the original tagged error.
195
+
196
+ `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`.
197
+
198
+ Install the optional router peer when using this entry point:
199
+
200
+ ```sh
201
+ pnpm add @tanstack/react-router
202
+ ```
203
+
204
+ ### 3. Once per feature: an editor and a create form
205
+
206
+ 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/effect-contract/README.md) describes that binding.
207
+
208
+ ```tsx
209
+ import { useCreate } from "@shivaedev/effect-react/create.ts";
210
+ import { useEditor } from "@shivaedev/effect-react/editor.ts";
211
+ import { useField } from "@shivaedev/effect-form/react.ts";
212
+ import type { Form } from "@shivaedev/effect-form/shape.ts";
213
+ import { Option, Schema } from "effect";
214
+ import { api, InvoiceLinesClient } from "./invoice-lines-client.ts";
215
+
216
+ const InvoiceLineFields = Schema.Struct({
217
+ name: Schema.String,
218
+ quantity: Schema.NumberFromString,
219
+ });
220
+ type Fields = typeof InvoiceLineFields.fields;
221
+
222
+ function Inputs<A, E, ER>({ form }: { readonly form: Form<Fields, A, E, ER> }) {
223
+ const name = useField(form, "name");
224
+ const quantity = useField(form, "quantity");
225
+ return (
226
+ <>
227
+ <label>
228
+ Name
229
+ <input name="name" value={name.value} onBlur={name.onBlur}
230
+ onChange={(event) => name.onChange(event.target.value)} />
231
+ </label>
232
+ {name.error && <p role="alert">{name.error}</p>}
233
+ <label>
234
+ Quantity
235
+ <input name="quantity" value={quantity.value} onBlur={quantity.onBlur}
236
+ onChange={(event) => quantity.onChange(event.target.value)} />
237
+ </label>
238
+ {quantity.error && <p role="alert">{quantity.error}</p>}
239
+ </>
240
+ );
241
+ }
29
242
 
30
- if (Option.isNone(orders.data)) {
31
- return Option.isSome(orders.cause)
32
- ? <button onClick={orders.refresh}>Retry</button>
33
- : <p>Loading…</p>
243
+ export function InvoiceLineEditor({ id }: { readonly id: number }) {
244
+ const editor = useEditor({
245
+ query: api.get.query({ id }),
246
+ fields: InvoiceLineFields,
247
+ values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
248
+ save: (values) => api.save.run({ id, ...values }),
249
+ runtime: InvoiceLinesClient.runtime,
250
+ });
251
+ if (editor.form === undefined) {
252
+ return Option.isSome(editor.query.cause)
253
+ ? <button onClick={editor.query.refresh}>Retry</button>
254
+ : <p>Loading…</p>;
34
255
  }
35
- return <OrderList orders={orders.data.value} refreshing={orders.refreshing}
36
- pending={rename.pending} onRename={save} />
256
+ return (
257
+ <form onSubmit={(event) => { event.preventDefault(); editor.save(); }}>
258
+ <Inputs form={editor.form} />
259
+ {Option.isSome(editor.query.cause) && <p role="alert">Could not refresh</p>}
260
+ {Option.isSome(editor.failure) && <p role="alert">Could not save</p>}
261
+ <button type="submit" disabled={editor.saving}>Save</button>
262
+ <button type="button" onClick={editor.query.refresh}>Refresh</button>
263
+ <button type="button" onClick={editor.revert}>Revert</button>
264
+ <p>{editor.saving ? "Saving" : editor.dirty ? "Unsaved" : "Saved"}</p>
265
+ </form>
266
+ );
37
267
  }
38
268
 
39
- function App() {
40
- return <RegistryProvider><Orders /></RegistryProvider>
269
+ export function InvoiceLineCreate() {
270
+ const create = useCreate({
271
+ fields: InvoiceLineFields,
272
+ initialValues: { name: "", quantity: "" },
273
+ create: api.create.run,
274
+ runtime: InvoiceLinesClient.runtime,
275
+ });
276
+ return (
277
+ <form onSubmit={(event) => { event.preventDefault(); create.save(); }}>
278
+ <Inputs form={create.form} />
279
+ {Option.isSome(create.failure) && <p role="alert">Could not create</p>}
280
+ <button type="submit" disabled={create.saving}>Create</button>
281
+ {Option.isSome(create.created) && <output>Created {create.created.value.name}</output>}
282
+ </form>
283
+ );
41
284
  }
42
285
  ```
43
286
 
44
- `Api` and `OrderList` above are application definitions. The hooks also accept non-RPC atoms built with `Atom.make`, `Atom.fn` or an atom runtime. Stable atom identities should be declared outside render, created with native `Atom.family`, or memoized; native `Api.query(...)` already supplies its own family.
287
+ 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.
45
288
 
46
- ## Editing
289
+ 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.
47
290
 
48
- `useEditor` from `@shivaedev/effect-react/editor.ts` and `useCreate` from `@shivaedev/effect-react/create.ts` bind `@shivaedev/effect-form` to a query atom and a save Effect, for example from an `@shivaedev/effect-contract` binding `api`. `@shivaedev/effect-form` is an optional peer dependency: install it only when you import these modules; the other modules do not load it. See the [editing guide](../../docs/framework/editing.md).
291
+ `save()` ignores a second call while saving. Submitting through effect-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.
292
+
293
+ 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`.
294
+
295
+ ### Field rejections and other failures
296
+
297
+ 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:
49
298
 
50
299
  ```ts
51
300
  const editor = useEditor({
52
- query: api.get.query({ id }),
53
- fields: InvoiceLineFields,
54
- values: (line) => ({ name: line.name, quantity: String(line.quantity) }),
55
- save: (values) => api.save.run({ id, ...values }),
56
- runtime: InvoiceLinesClient.runtime,
57
- })
58
- // editor.query: QueryState; editor.form: Form | undefined until data first arrives
59
- // editor.save(), editor.saving, editor.dirty, editor.failure (non-field failures), editor.revert()
301
+ query,
302
+ fields: Schema.Struct({ title: Schema.String }),
303
+ values: (row) => ({ title: row.title }),
304
+ save,
305
+ runtime,
306
+ rejectField: (error) => error._tag === "SubtitleRejected"
307
+ ? { field: "title", message: error.message }
308
+ : undefined,
309
+ });
310
+ ```
311
+
312
+ 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.
313
+
314
+ ### Resume and refresh policy
315
+
316
+ Keep the signal and query identities stable:
317
+
318
+ ```ts
319
+ import { resumeSignal } from "@shivaedev/effect-react/resume-signal.ts";
320
+ import { Effect } from "effect";
321
+ import * as Atom from "effect/unstable/reactivity/Atom";
322
+
323
+ const resume = resumeSignal({ window });
324
+ const query = Atom.makeRefreshOnSignal(resume)(Atom.make(Effect.succeed("ready")));
325
+ ```
326
+
327
+ `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 [effect-contract resume implementation](https://github.com/ShivaeDev/platform/blob/main/packages/effect-contract/src/resume.ts).
328
+
329
+ Use native stale-while-revalidate policy when a resume should refresh only stale data:
330
+
331
+ ```ts
332
+ const query = Atom.swr(atom, {
333
+ focusSignal: resume,
334
+ revalidateOnFocus: true,
335
+ staleTime: "1 hour",
336
+ });
60
337
  ```
61
338
 
62
- The form is created from the first successful value and replaced when the query atom changes, so a different id never inherits a draft. Each new query value and each saved result is received per field: untouched fields adopt it, fields edited meanwhile keep local input. A tagged failure shaped `{ _tag, field, message }` whose `field` names a form field, such as an `@shivaedev/effect-contract` `fieldRejection`, becomes that field's message; pass `rejectField` to map differently. When a tagged rejection's `field` type names a field the form lacks, `rejectField` is required at compile time. Other failures appear in `failure`. `save()` does nothing while a save is running; submitting through the form's own `submit` atom settles the same way. Render fields with `useField(editor.form, name)` from `@shivaedev/effect-form/react`.
339
+ `atom` is the underlying native query. `Atom.swr` owns the staleness decision; the signal only reports that the application resumed.
340
+
341
+ ### API
342
+
343
+ `useQuery(atom)` and `useAction(atom)` return these shared fields:
63
344
 
64
- `useCreate({ fields, initialValues, create, runtime, rejectField })` returns the same save state with an always-present `form` and `created: Option<A>`. After success it replaces the form with a fresh one at `initialValues`, carrying over fields changed while the create was in flight, so their messages and submitted state do not leak into the next entry.
345
+ | Field | Meaning |
346
+ | --- | --- |
347
+ | `result` | Native `AsyncResult<A, E>`. |
348
+ | `data` | `Option<A>` from the current or retained success. Queries can retain displayed data even when the native `result` has no value. |
349
+ | `cause` | `Option<Cause<E>>` from a failed result. |
350
+ | `pending` | The native result is waiting. |
351
+ | `refreshing` | The result is waiting and successful data is available. |
65
352
 
66
- ## State
353
+ `useQuery(atom, options?)` adds `refresh(): void`; `useAction` adds `dispatch(input): void`. `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.
67
354
 
68
- Both hooks return:
355
+ | Defining module | Exports |
356
+ | --- | --- |
357
+ | `result-state.ts` | `useQuery`, `useAction`, `isUnauthorized`; `ResultState`, `QueryOptions`, `QueryState`, `ActionState`. |
358
+ | `session-boundary.ts` | `SessionBoundary`, `useSessionRecheck`; `SessionBoundaryProps`. |
359
+ | `editor.ts` | `useEditor`; `Editor`, `EditorConfig`. |
360
+ | `create.ts` | `useCreate`; `Create`, `CreateConfig`. |
361
+ | `resume-signal.ts` | `resumeSignal`; `ResumeSource`, `ResumeWindow`, `ResumeOptions`. |
362
+ | `router.ts` | `loadQuery`. |
363
+ | `save-state.ts` | `useSaveState`; `SaveState`. |
364
+ | `field-rejection.ts` | `fieldRejectionOf`, `rejecting`; `FieldRejection`, `RejectField`, `FieldRejectionMapping`, `AtomServices`. |
365
+ | `testing.ts` | `render`, `eventually`, `expectNoReactWarnings`; `Rendered`. |
366
+ | `submission.ts` | `submission`; `Submission`, `SubmissionConfig`. |
69
367
 
70
- - `result`: the unchanged native `AsyncResult`, including its discriminant and typed failure.
71
- - `data`: `Option<A>`, using the current or previous successful result. A valid `undefined` success remains `Some(undefined)`.
72
- - `cause`: `Option<Cause<E>>`, preserving failures, defects and interruption information.
73
- - `pending`: whether the native result is waiting, including refreshes.
74
- - `refreshing`: waiting while successful data is available.
368
+ Import the defining `.ts` module, such as `@shivaedev/effect-react/editor.ts`. There is no root import. `SessionBoundaryProps` supplies `session`, `identify`, `connect`, `recheck`, a client-to-children function and optional `signedOut`; `useSessionRecheck()` reads that boundary's callback.
75
369
 
76
- `useQuery(atom)` additionally returns `refresh()`. It does not carry data across different query atoms. `useAction(atom)` additionally returns `dispatch(input): void`. Read its ordinary success value from `data` and errors from `cause` or the discriminated `result`. Dispatch does not return a completion promise. For imperative per-invocation outcomes (such as closing only the editor whose save succeeded), execute the actual native RPC Effect through your application runtime.
370
+ `Editor` exposes `query`, optional `form` and `SaveState`; `Create` exposes `form`, `created` and `SaveState`. `SaveState` has `save`, `saving`, `dirty`, `failure` and `revert`. Prefer the two complete hooks when binding a form to a command. The lower-level form modules expose the same composition pieces: `submission` makes a form and its submission snapshot reader, `rejecting` maps save failures through a submitter, and `useSaveState` observes submission and invokes a success callback.
371
+
372
+ ### Render tests in happy-dom
373
+
374
+ Use `@shivaedev/effect-react/testing.ts` with Vitest's `happy-dom` environment. Install effect-contract for the example's query and command declarations, and the testing peers and environment in your app's dev dependencies:
375
+
376
+ ```sh
377
+ pnpm add @shivaedev/effect-contract
378
+ pnpm add -D vitest happy-dom react-dom@19.2.8 @types/react-dom
379
+ ```
380
+
381
+ 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:
382
+
383
+ ```tsx
384
+ import { Effect, Option, Ref, Schema } from "effect";
385
+ import * as AtomRpc from "effect/unstable/reactivity/AtomRpc";
386
+ import { RpcTest } from "effect/unstable/rpc";
387
+ import { act, createElement } from "react";
388
+ import { expect, it } from "vitest";
389
+ import { bind } from "@shivaedev/effect-contract/bind.ts";
390
+ import { contract } from "@shivaedev/effect-contract/contract.ts";
391
+ import { collection } from "@shivaedev/effect-contract/keys.ts";
392
+ import { command, query } from "@shivaedev/effect-contract/operation.ts";
393
+ import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts";
394
+ import { eventually, expectNoReactWarnings, render } from "@shivaedev/effect-react/testing.ts";
77
395
 
78
- ## Scope and concurrency
396
+ Object.assign(globalThis, { "IS_REACT_ACT_ENVIRONMENT": true });
397
+ expectNoReactWarnings();
79
398
 
80
- Mount `SessionBoundary` at the authenticated root. For each session key it builds one credential-bound client and one registry, remounts its subtree when the key changes, renders `signedOut` without a session and disposes the outgoing registry. `connect` runs once per key, so a client must read a rotating credential per request, or the key must include a credential generation. A subtree hidden by `<Activity>` keeps its state and gets a fresh registry on reveal. Inside it, `useQuery` and `useAction` call the boundary's `recheck` when a result fails with a tagged `Unauthorized` error; they keep retained data until the auth owner ends the session. See [client lifecycle](../../docs/framework/client-lifecycle.md). SSR isolation is not implemented.
399
+ const notes = collection("notes", Schema.Number);
400
+ const List = query("list", { reads: () => [notes.list], success: Schema.Array(Schema.String) });
401
+ const Add = command("add", { invalidates: () => [notes.list], payload: { title: Schema.String } });
402
+ const Notes = contract("notes", { commands: [Add], queries: [List] });
403
+
404
+ const handlers = Notes.toLayer(
405
+ Effect.gen(function* () {
406
+ const stored = yield* Ref.make<readonly string[]>([]);
407
+ return Notes.of({
408
+ "notes.add": ({ title }) => Ref.update(stored, (all) => [...all, title]),
409
+ "notes.list": () => Ref.get(stored).pipe(Effect.delay("20 millis")),
410
+ });
411
+ }),
412
+ );
413
+ class Client extends AtomRpc.Service<Client>()("test/Notes", {
414
+ group: Notes,
415
+ makeEffect: RpcTest.makeClient(Notes, { flatten: true }),
416
+ protocol: handlers,
417
+ }) {}
418
+ const api = bind(Notes, Client);
419
+ const add = Client.runtime.fn(api.add.run);
420
+ function NotesView() {
421
+ const list = useQuery(api.list.query());
422
+ const action = useAction(add);
423
+ return createElement(
424
+ "section",
425
+ null,
426
+ createElement("output", null, Option.getOrElse(list.data, () => []).join(", ")),
427
+ createElement(
428
+ "button",
429
+ {
430
+ disabled: list.pending || action.pending,
431
+ onClick: () => action.dispatch({ title: "Morning walk" }),
432
+ type: "button",
433
+ },
434
+ "Add",
435
+ ),
436
+ );
437
+ }
438
+
439
+ it("renders a query and refetches it after a command through a test RPC layer", async () => {
440
+ const view = await render(createElement(NotesView));
441
+ try {
442
+ await eventually(() => expect(view.container.querySelector("button")?.disabled).toBe(false));
443
+ expect(view.container.querySelector("output")?.textContent).toBe("");
444
+ await act(async () => view.container.querySelector("button")?.click());
445
+ await eventually(() => {
446
+ expect(view.container.querySelector("output")?.textContent).toBe("Morning walk");
447
+ expect(view.container.querySelector("button")?.disabled).toBe(false);
448
+ });
449
+ } finally {
450
+ await view.unmount();
451
+ }
452
+ });
453
+ ```
454
+
455
+ `render(node)` 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. Always unmount in `finally`. Keep component interactions inside React's `act`. `eventually(assert)` retries the assertion for up to three seconds, allowing pending responses to settle inside `act` between attempts; it throws the last assertion failure on timeout. `expectNoReactWarnings()` registers a Vitest hook that fails a test if it logged to `console.error`.
456
+
457
+ The RPC layer stays application-owned: replace these in-memory handlers with the service Layer your component needs. This test proves rendered query data and command-triggered invalidation in happy-dom, without an HTTP server or a physical browser. The generic `effect-test` package has no React dependency.
458
+
459
+ ### Testing a feature through its stack
460
+
461
+ The [order feature’s application-owned kit](https://github.com/ShivaeDev/platform/tree/main/packages/effect-react/src/test-support/order-example) uses `test-story` above `effect-test` to compose real PostgreSQL, HTTP RPC and the actual React components. Its screen traits mount fresh native atom registries and scoped roots; its verbs dispatch DOM events and wait for rendered output. The [stories](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/src/orderStory.dom.spec.tsx) verify list refresh through `useAction` and rejected-input retention and retry through `useEditor`, with persistence read from a separate PostgreSQL pool. These tests require `PLATFORM_EFFECT_SQL_TEST_DATABASE_URL`; DOM and loopback HTTP establish no physical browser, SSR or mobile behavior.
462
+
463
+ ### Install, setup and limits
464
+
465
+ ```sh
466
+ pnpm add @shivaedev/effect-react @effect/atom-react@4.0.0-rc.112 effect@4.0.0-rc.112 react@19.2.8
467
+ ```
468
+
469
+ For editor, create or lower-level form modules, also install the optional form peer:
470
+
471
+ ```sh
472
+ pnpm add @shivaedev/effect-form
473
+ ```
81
474
 
82
- `resumeSignal({ window, native })` is an atom that increments when the page becomes visible, when the network returns while visible, and when an injected native resume source fires. Feed it to native `Atom.makeRefreshOnSignal` or to `Atom.swr` as `focusSignal`.
475
+ Query, action, session and resume modules do not require effect-form. The package declares Node 24 or newer and peers on Effect, atom-react and React; this repository checks Effect and atom-react `4.0.0-rc.112` with React `19.2.8`.
83
476
 
84
- State belongs to the **supplied atom in the registry**, not to an individual hook call. In particular, native `Api.mutation("RenameOrder")` returns the same atom for that operation. Components using it share pending/result state and the native action's concurrency behavior. With the default native action, a later dispatch interrupts the earlier one; shared state then represents the later action. Disable duplicate submissions, or create distinct native action atoms when independent lifetimes are needed. Native registry retention and disposal govern cleanup. The package adds no optimistic policy, offline queue, form state or parallel action scheduler.
477
+ - Query identity, TTL, invalidation and action concurrency remain native atom policies. Put the registry at the lifetime boundary those policies need.
478
+ - Action state describes the atom's latest result. A `dispatch` is not a Promise or a separate result handle.
479
+ - A session generation is application-defined. Credentials captured by `connect` require a changed generation when they rotate.
480
+ - The package has no SSR registry/hydration adapter, offline write queue or optimistic reconciliation policy.
481
+ - DOM tests and an injected resume source do not prove real-browser or mobile lifecycle behavior. The real HTTP order fixture proves its local composition, not a deployed host's auth, cancellation or rendering policy.
85
482
 
86
- ## Validation
483
+ The full design is in [the north star](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/docs/north-star.md). Implementation status and unresolved choices are in [the roadmap](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/docs/roadmap.md).
87
484
 
88
- React DOM tests exercise native atom execution, refresh retention, typed failures and query identity changes. Compiler fixtures check success/input/error inference. The package smoke test installs its tarball into a standalone consumer and checks both compiler versions and NodeNext resolution.
485
+ The [reference PostgreSQL feature](https://github.com/ShivaeDev/platform/blob/main/docs/framework/composed-example.md) uses the same leased database infrastructure and a scoped story verb for its real server and rendered clients. It is a test fixture, not a copyable app; an app template is still needed. Its stories verify commit visibility, live refresh of a second editor, retained local edits and rollback without publication.
@@ -0,0 +1,17 @@
1
+ import { type Effect, Option, type Schema } from "effect";
2
+ import type * as Atom from "effect/unstable/reactivity/Atom";
3
+ import type { Decoded, Encoded, FieldFailure, Fields, Form, Services } from "@shivaedev/effect-form/shape.ts";
4
+ import type { AtomServices, FieldRejectionMapping } from "#field-rejection.ts";
5
+ import { type SaveState } from "./saveState.ts";
6
+ export type CreateConfig<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError> = {
7
+ readonly fields: Schema.Struct<TFields>;
8
+ readonly initialValues: Encoded<TFields>;
9
+ readonly create: (values: Decoded<TFields>) => Effect.Effect<TCreated, TSaveError, AtomServices<R>>;
10
+ readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
11
+ } & FieldRejectionMapping<TFields, TSaveError>;
12
+ export interface Create<TFields extends Fields, TCreated, TSaveError, TRuntimeError> extends SaveState<TSaveError | TRuntimeError> {
13
+ readonly created: Option.Option<TCreated>;
14
+ readonly form: Form<TFields, TCreated, TSaveError | FieldFailure, TRuntimeError>;
15
+ }
16
+ export declare function useCreate<TFields extends Fields, TCreated, TSaveError, R, TRuntimeError>(config: CreateConfig<TFields, TCreated, TSaveError, R, TRuntimeError>): Create<TFields, TCreated, TSaveError, TRuntimeError>;
17
+ //# sourceMappingURL=create.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/client/create.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAS,MAAM,EAAE,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AACjE,OAAO,KAAK,KAAK,IAAI,MAAM,iCAAiC,CAAC;AAE7D,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAC9G,OAAO,KAAK,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE/E,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,gBAAgB,CAAC;AAC9D,MAAM,MAAM,YAAY,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,IAAI;IAC1F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,UAAU,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;IACpG,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC;CACxE,GAAG,qBAAqB,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;AAC/C,MAAM,WAAW,MAAM,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,CAAE,SAAQ,SAAS,CAAC,UAAU,GAAG,aAAa,CAAC;IACjI,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1C,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,GAAG,YAAY,EAAE,aAAa,CAAC,CAAC;CACjF;AAKD,wBAAgB,SAAS,CAAC,OAAO,SAAS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,EACvF,MAAM,EAAE,YAAY,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,EAAE,aAAa,CAAC,GACnE,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,CAAC,CAiBtD"}