rxfy-react 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,36 +1,406 @@
1
1
  # rxfy-react
2
2
 
3
- `rxfy-react` official bindings of `rxfy` for `react`
3
+ `rxfy-react` is the official React bindings for [`rxfy`](../rxfy/README.md). Subscribe React components to normalized entities; each renders the one shared copy and updates live.
4
4
 
5
- # install
5
+ ## Install
6
6
 
7
- `pnpm install rxfy-react`
7
+ ```bash
8
+ npm install rxfy rxfy-react
9
+ # or: pnpm add rxfy rxfy-react
10
+ ```
11
+
12
+ ## Peer dependencies
13
+
14
+ ```json
15
+ {
16
+ "@types/react": "^18.0.0 || ^19.0.0",
17
+ "react": "^18.0.0 || ^19.0.0",
18
+ "react-dom": "^18.0.0 || ^19.0.0",
19
+ "rxfy": "*",
20
+ "lodash": "^4.0.0",
21
+ "next": ">=14"
22
+ }
23
+ ```
24
+
25
+ `next` is **optional**; only needed for the `rxfy-react/next` subpath (Next.js App Router streaming).
26
+
27
+ ---
28
+
29
+ ## Setup
30
+
31
+ Wrap your app (or the relevant subtree) with `StoreProvider`. This creates the model registry that `useStateData` and `useModelStore` write to and read from.
32
+
33
+ ```tsx
34
+ import { StoreProvider } from "rxfy-react";
35
+ import { createRoot } from "react-dom/client";
36
+
37
+ createRoot(document.getElementById("root")!).render(
38
+ <StoreProvider>
39
+ <App />
40
+ </StoreProvider>,
41
+ );
42
+ ```
43
+
44
+ **Signature:**
45
+
46
+ ```ts
47
+ function StoreProvider(props: PropsWithChildren<{
48
+ ssr?: boolean; // enables server-side fetch-and-suspend in useStateData
49
+ registry?: IModelRegistry; // per-request registry created by server code (for dehydrate)
50
+ dehydratedState?: DehydratedState; // snapshot for custom transports; usually unnecessary, see below
51
+ }>): JSX.Element
52
+ ```
53
+
54
+ All three props exist for [SSR](#server-side-rendering); a plain client-only app uses none of them. On the client the provider automatically ingests `window.__RXFY_SSR__` chunks: both the snapshot injected by `hydrationScript` and the streamed pushes from `<HydrationStream />`, including chunks arriving after hydration starts. `dehydratedState` is only needed when the snapshot travels by some other channel (a framework loader, tests).
55
+
56
+ ---
57
+
58
+ ## High-level hooks
59
+
60
+ ### `useStateData`
61
+
62
+ Fetches data, normalizes entities into model stores, and returns a `StateHandle`. **`data$` emits the normalized query shape: entity ids, not entities** (`array` fields → `string[]`, `single` fields → `string`). Render lists by id and read entity data through [`useModelStore`](#usemodelstore); that's the only place entity values live, so a stale read is impossible by construction.
63
+
64
+ `fetchFn` returns the full fetch shape (entities) and receives an `AbortSignal` that fires on cleanup. `mutations` and `set` also operate on full entities: rxfy denormalizes the current ids into the freshest store values, runs your reducer, and normalizes the result back, so one call updates both membership and entity data.
65
+
66
+ ```tsx
67
+ import { useMemo, useState } from "react";
68
+ import { useStateData, useModelStore, Pending } from "rxfy-react";
69
+ import { todosState, fetchTodos, TodoModel } from "./todos";
70
+
71
+ function TodoApp() {
72
+ const [filter, setFilter] = useState<"all" | "active" | "done">("all");
73
+ const params = useMemo(() => ({ filter }), [filter]);
74
+
75
+ const { data$, mutations, reload } = useStateData(todosState, fetchTodos, params);
76
+
77
+ return (
78
+ <Pending value$={data$} pending={<p>Loading...</p>}>
79
+ {({ todos }) => (
80
+ <>
81
+ <ul>{todos.map((id) => <TodoItem key={id} id={id} />)}</ul>
82
+ <button onClick={() => mutations.addTodo({ id: crypto.randomUUID(), title: "new", done: false })}>
83
+ Add
84
+ </button>
85
+ <button onClick={reload}>Reload</button>
86
+ </>
87
+ )}
88
+ </Pending>
89
+ );
90
+ }
91
+
92
+ function TodoItem({ id }: { id: string }) {
93
+ const store = useModelStore(TodoModel);
94
+ const todo$ = useMemo(() => store.get(id), [store, id]);
95
+ return <Pending value$={todo$}>{(todo) => <li>{todo.title}</li>}</Pending>;
96
+ }
97
+ ```
98
+
99
+ **Signature:**
100
+
101
+ ```ts
102
+ function useStateData<TParams, TShape, TMutations>(
103
+ state: StateDescriptor<TParams, TShape, TMutations>,
104
+ fetchFn: (params: TParams, signal: AbortSignal) => Promise<TShape>,
105
+ params: TParams,
106
+ ): StateHandle<TShape, TMutations>
8
107
 
9
- # api
108
+ // StateHandle<TShape, TMutations>: {
109
+ // data$: Observable<QueryShapeOf<TShape>>; // ids only
110
+ // set: (value: TShape | ((prev: TShape) => TShape)) => void; // full entities
111
+ // reload: () => void;
112
+ // mutations: BoundMutations<TShape, TMutations>; // full entities
113
+ // }
114
+ ```
115
+
116
+ **Caching semantics** (states with a `key`): results, mutations, and `set` write through to the registry's query cache, so a remount with the same params starts from the cached ids without re-fetching, while entity values always come live from model stores (a websocket-style `store.set` between mounts is never clobbered). `reload()` deletes the cache entry and re-fetches. Keyless states skip the cache entirely and fetch per mount.
117
+
118
+ **During SSR** (inside `<StoreProvider ssr>` on the server): a cache miss calls `fetchFn` and suspends until it settles; concurrent components with the same key share one fetch. Rejections are captured and hydrate as rejected state; `<Pending rejected>` renders them with a working retry. See [Server-side rendering](#server-side-rendering).
119
+
120
+ ### `useModelStore`
121
+
122
+ Returns the `ModelStore` for a model descriptor. Lets a component subscribe to a single normalized entity that was populated by `useStateData` or a direct `store.set` call, without re-fetching the full list.
123
+
124
+ ```tsx
125
+ import { useMemo } from "react";
126
+ import { useModelStore, Pending } from "rxfy-react";
127
+ import { TodoModel } from "./models";
128
+
129
+ function TodoItem({ id }: { id: string }) {
130
+ const store = useModelStore(TodoModel);
131
+ const todo$ = useMemo(() => store.get(id), [store, id]);
132
+
133
+ return (
134
+ <Pending value$={todo$}>
135
+ {(todo) => <li>{todo.title}</li>}
136
+ </Pending>
137
+ );
138
+ }
139
+ ```
140
+
141
+ **Signature:**
142
+
143
+ ```ts
144
+ function useModelStore<T>(descriptor: ModelDescriptor<T>): ModelStore<T>
145
+ // ModelStore<T>: {
146
+ // get(key: string): Observable<T>; // reactive read; emits on every change
147
+ // set(key, val): void;
148
+ // setMany(items): void;
149
+ // getValue(key: string): T | undefined; // synchronous read
150
+ // entity(key: string): IAtom<T>; // writable handle over one entity's cell
151
+ // valueEntries(): [string, T][];
152
+ // added$: Observable<string>; // a key, the first time its entity appears
153
+ // }
154
+ ```
155
+
156
+ > **Note:** `store.get(id)` returns an Observable that never emits until `set` or `setMany` is called for that key. It stays in pending state until data arrives, typically populated by a `useStateData` call that includes this model in its `model` field.
10
157
 
11
- - useEdge — use edge state
12
- - <Edge /> — useEdge wrapped with render props pattern
158
+ ### `useAtom`
13
159
 
14
- # example
160
+ Binds any `IAtom<T>` (a plain `Atom`, a field `Lens`, or a `ModelStore.entity(id)` handle) to React as `[value, setValue]`. Paired with `store.entity(id)` and `keyLens`, it gives two-way form binding that stays in sync across every component subscribed to that entity: typing writes through the field `Lens` into the entity cell, so the edit reaches the list row, the detail header, and any other subscriber with no re-fetch.
15
161
 
16
162
  ```tsx
17
- import PQueue from "p-queue";
18
163
  import { useMemo } from "react";
19
- import { of } from "rxjs";
20
- import { createAtom, createState, createStore } from "rxfy";
21
- import { Edge } from "rxfy-react";
164
+ import { createLens, keyLens } from "rxfy";
165
+ import { useAtom, useModelStore } from "rxfy-react";
166
+ import { TodoModel } from "./models";
167
+
168
+ function EditTitle({ id }: { id: string }) {
169
+ const store = useModelStore(TodoModel);
170
+ const todo$ = useMemo(() => store.entity(id), [store, id]); // IAtom<Todo>
171
+ const title$ = useMemo(() => createLens(todo$, keyLens("title")), [todo$]);
172
+ const [title, setTitle] = useAtom(title$);
173
+
174
+ return <input value={title} onChange={(e) => setTitle(e.target.value)} />;
175
+ }
176
+ ```
177
+
178
+ **Signature:**
179
+
180
+ ```ts
181
+ function useAtom<T>(atom$: IAtom<T>): [T, (value: T) => void]
182
+ ```
183
+
184
+ > `atom$` must be referentially stable across renders; wrap `store.entity(id)` and the `Lens` in `useMemo` as above. `store.entity(id)` assumes the entity is already loaded.
185
+
186
+ ---
187
+
188
+ ## Rendering helpers
189
+
190
+ ### `Pending`
191
+
192
+ Subscribes to any `ObservableLike<T>` and renders the appropriate UI for pending, rejected, or fulfilled state. The `rejected` render prop receives the rejected `IWrapped` (`{ type, error }`). If `rejected` is not provided, it defaults to rendering nothing and logging the error to `console.error`.
193
+
194
+ Reload is not carried on the status object: get it from the `useStateData` handle's `reload()` (or `getAttachedReload(source$)` from `rxfy` for a standalone observable).
195
+
196
+ ```tsx
197
+ import { Pending, useStateData } from "rxfy-react";
198
+
199
+ const { data$, reload } = useStateData(todosState, fetchTodos, params);
200
+
201
+ <Pending
202
+ value$={data$}
203
+ pending={<p>Loading...</p>}
204
+ rejected={({ error }) => (
205
+ <p>
206
+ Error: {String(error)} <button onClick={reload}>Retry</button>
207
+ </p>
208
+ )}
209
+ >
210
+ {(value) => <div>{JSON.stringify(value)}</div>}
211
+ </Pending>
212
+ ```
213
+
214
+ **Signature:**
215
+
216
+ ```tsx
217
+ function Pending<T>(props: {
218
+ value$: ObservableLike<T>;
219
+ pending?: IRenderable<void>;
220
+ rejected?: IRenderable<IWrapped<T, StatusEnum.REJECTED>>; // { type, error }
221
+ children: IRenderable<T>;
222
+ getDefaultValue?: () => T;
223
+ }): JSX.Element
224
+
225
+ // IRenderable<T> = React.ReactNode | ((data: T) => React.ReactNode)
226
+ // ObservableLike<T> = Observable<T> | T
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Low-level hooks
232
+
233
+ ### `usePending`
234
+
235
+ Tracks any `ObservableLike<T>` and returns the current [`IWrapped<T>`](../rxfy/README.md), narrowed to `PENDING` / `FULFILLED` / `REJECTED` (never `IDLE`). This is the hook powering `<Pending>`. Narrow on `type` to read `value` (only on `FULFILLED`) or `error` (only on `REJECTED`). Pass `getDefaultValue` to start `FULFILLED` instead of `PENDING`.
236
+
237
+ ```ts
238
+ import { usePending } from "rxfy-react";
239
+ import { StatusEnum } from "rxfy";
240
+
241
+ const wrapped = usePending(data$);
242
+ if (wrapped.type === StatusEnum.PENDING) return <span>…</span>;
243
+ if (wrapped.type === StatusEnum.REJECTED) return <span>error</span>;
244
+ return <span>{JSON.stringify(wrapped.value)}</span>;
245
+ ```
246
+
247
+ **Signature:**
248
+
249
+ ```ts
250
+ function usePending<T>(
251
+ source$: ObservableLike<T>,
252
+ getDefaultValue?: () => T,
253
+ ): IWrapped<T, StatusEnum.PENDING | StatusEnum.FULFILLED | StatusEnum.REJECTED>
254
+ ```
255
+
256
+ > **Reload** is not part of the status object; trigger it via the `useStateData` handle's `reload()` or `getAttachedReload(source$)` from `rxfy`.
257
+ >
258
+ > **Contract:** `source$` must be referentially stable across renders (memoize it; `data$` from `useStateData` already is). A new identity restarts the pipeline from `PENDING`; an observable created inline in render restarts every render and never settles.
259
+
260
+ ### `useObservable`
261
+
262
+ Low-level hook that subscribes to any `Observable<T>` via `useSyncExternalStore`.
263
+
264
+ ```ts
265
+ import { useObservable } from "rxfy-react";
266
+
267
+ const value = useObservable(observable$, defaultValue);
268
+ const maybeValue = useObservable(observable$); // T | undefined
269
+ ```
270
+
271
+ **Signature:**
272
+
273
+ ```ts
274
+ function useObservable<T>(observable: Observable<T>, initialValue: T): T
275
+ function useObservable<T>(observable: Observable<T>): T | undefined
276
+ ```
277
+
278
+ Emissions that are deep-equal (`lodash.isEqual`) to the current value are skipped; re-emitting an identical value does not re-render.
279
+
280
+ ---
281
+
282
+ ## Context / registry internals
283
+
284
+ `ModelRegistryContext` and `useModelRegistry` expose the underlying React context. Use them directly only when building a custom provider or accessing the registry outside `StoreProvider`.
285
+
286
+ ```tsx
287
+ import { ModelRegistryContext, useModelRegistry } from "rxfy-react";
288
+ import { createModelRegistry } from "rxfy";
289
+
290
+ // custom provider
291
+ const registry = createModelRegistry();
292
+ <ModelRegistryContext.Provider value={registry}>
293
+ <App />
294
+ </ModelRegistryContext.Provider>
295
+
296
+ // inside any child; throws if no provider is found above
297
+ const registry = useModelRegistry();
298
+ ```
299
+
300
+ **Signatures:**
22
301
 
23
- const queue = new PQueue({ concurrency: 5 });
24
- const state = createAtom(createState({}));
25
- const store = createStore(queue, state);
26
- const userStore = store.factory("users", (id) => of({ id }));
302
+ ```ts
303
+ const ModelRegistryContext: React.Context<IModelRegistry | null>
304
+ function useModelRegistry(): IModelRegistry
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Server-side rendering
310
+
311
+ SSR is on-demand: there is no prefetch API. Components declare their data with `useStateData` exactly as on the client; on the server (`<StoreProvider ssr>`) a cache miss suspends until the fetch settles. Results (fulfilled or rejected) are captured in the registry, serialized into the HTML, and ingested on the client so the first paint is already fulfilled: no loading flash, no re-fetch, no hydration mismatch.
312
+
313
+ Requirements: give models a `name` and states a `key` (stable string identities), and write `fetchFn` to work in both environments (it runs on the server during SSR and on the client for reloads).
314
+
315
+ ### Buffered mode (any Node server)
316
+
317
+ Wait for every Suspense boundary with `onAllReady`, then send the complete document. This is the recommended non-Next mode; [examples/vite-todo](../../examples/vite-todo) runs it end to end.
318
+
319
+ ```tsx
320
+ // server
321
+ import { renderToPipeableStream } from "react-dom/server";
322
+ import { createModelRegistry, dehydrate, hydrationScript } from "rxfy";
323
+ import { StoreProvider } from "rxfy-react";
324
+
325
+ function render(): Promise<{ html: string; state: string }> {
326
+ const registry = createModelRegistry(); // one per request
327
+ return new Promise((resolve, reject) => {
328
+ const { pipe } = renderToPipeableStream(
329
+ <StoreProvider registry={registry} ssr><App /></StoreProvider>,
330
+ {
331
+ onAllReady() {
332
+ // collect the stream into a string, then:
333
+ // resolve({ html, state: hydrationScript(dehydrate(registry)) });
334
+ },
335
+ onError: reject,
336
+ },
337
+ );
338
+ });
339
+ }
340
+
341
+ // template: <div id="root"><!--app-html--></div><!--app-state-->
342
+ // inject: template.replace("<!--app-state-->", state)
343
+ ```
344
+
345
+ ```tsx
346
+ // client: StoreProvider picks the snapshot up from the injected script automatically
347
+ import { hydrateRoot } from "react-dom/client";
348
+
349
+ hydrateRoot(
350
+ document.getElementById("root")!,
351
+ <StoreProvider ssr><App /></StoreProvider>,
352
+ );
353
+ ```
354
+
355
+ ### Streaming mode (Next.js App Router)
27
356
 
28
- export function User({ userId }: { userId: string }) {
29
- const user = useMemo(() => userStore.get(userId), [userId]);
357
+ `rxfy-react/next` ships `<HydrationStream />`: on each stream flush it emits newly settled queries and newly written entities as `window.__RXFY_SSR__.push(...)` script tags; the client `StoreProvider` ingests them, including chunks arriving after hydration starts.
358
+
359
+ ```tsx
360
+ // app/providers.tsx
361
+ "use client";
362
+ import { StoreProvider } from "rxfy-react";
363
+ import { HydrationStream } from "rxfy-react/next";
364
+
365
+ export function Providers({ children }: { children: React.ReactNode }) {
30
366
  return (
31
- <Edge edge={user} pending={<span>Loading..</span>} rejected={(err) => <span>{JSON.stringify(err)}</span>}>
32
- {(user) => <div key={user.id}>{user.id}</div>}
33
- </Edge>
367
+ <StoreProvider ssr>
368
+ <HydrationStream />
369
+ {children}
370
+ </StoreProvider>
34
371
  );
35
372
  }
36
373
  ```
374
+
375
+ `next` is an optional peer dependency; only this subpath needs it.
376
+
377
+ ### Two-pass mode (strict `renderToString`)
378
+
379
+ For environments without React stream APIs, `collectStateData` loops render passes until nothing suspends (the `getDataFromTree` pattern; each fetch waterfall level costs one extra pass):
380
+
381
+ ```ts
382
+ import { renderToString } from "react-dom/server";
383
+ import { collectStateData } from "rxfy-react";
384
+
385
+ const html = await collectStateData(registry, () =>
386
+ renderToString(<StoreProvider registry={registry} ssr><App /></StoreProvider>),
387
+ );
388
+ const state = hydrationScript(dehydrate(registry));
389
+ ```
390
+
391
+ **Signature:**
392
+
393
+ ```ts
394
+ function collectStateData(registry: IModelRegistry, render: () => string): Promise<string>
395
+ ```
396
+
397
+ ### Error handling
398
+
399
+ A `fetchFn` rejection on the server is captured as a serialized rejected entry (`{ name, message }`, stack stripped) and hydrates as rejected state: the server HTML shows your `<Pending rejected>` UI, and the handle's `reload()` retries client-side with a real fetch.
400
+
401
+ ---
402
+
403
+ ## See also
404
+
405
+ - [rxfy: Core API](../rxfy/README.md)
406
+ - [examples/vite-todo](../../examples/vite-todo): full working example with buffered SSR and URL-driven state
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __commonJS = (cb, mod) => function __require() {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+
28
+ // src/registry-context.ts
29
+ import { createContext, useContext } from "react";
30
+ var ModelRegistryContext = createContext(null);
31
+ function useModelRegistry() {
32
+ const ctx = useContext(ModelRegistryContext);
33
+ if (!ctx) throw new Error("StoreProvider not found");
34
+ return ctx;
35
+ }
36
+
37
+ export {
38
+ __commonJS,
39
+ __toESM,
40
+ ModelRegistryContext,
41
+ useModelRegistry
42
+ };