react-stateshape 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Alexander Tkačenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,475 @@
1
+ # react-stateshape
2
+
3
+ Shared state management and routing in React apps. Under the hood, routing is shared state management, too, with the shared data being the URL.
4
+
5
+ Contents: [useExternalState](#useexternalstate) · [useRoute](#useroute) · [useNavigationStart / useNavigationComplete](#usenavigationstart--usenavigationcomplete) · [useRouteState](#useroutestate) · [Type-safe routes](#type-safe-routes) · [useTransientState](#usetransientstate) · [Annotated examples](#annotated-examples) · [Internals](#internals)
6
+
7
+ ## useExternalState
8
+
9
+ This hook is focused on simplicity of both setting up shared state from scratch and migrating from local state. The equally common latter scenario is often missed out with commonly used approaches resulting in sizable code rewrites.
10
+
11
+ ### Shared state
12
+
13
+ Move local state to the full-fledged shared state with minimal paradigm shift and minimal code changes:
14
+
15
+ ```diff
16
+ + import { State, useExternalState } from "react-stateshape";
17
+ +
18
+ + let counterState = new State(0);
19
+
20
+ let Counter = () => {
21
+ - let [counter, setCounter] = useState(0);
22
+ + let [counter, setCounter] = useExternalState(counterState);
23
+
24
+ let handleClick = () => setCounter((value) => value + 1);
25
+
26
+ return <button onClick={handleClick}>+ {counter}</button>;
27
+ };
28
+
29
+ let ResetButton = () => {
30
+ - let [, setCounter] = useState(0);
31
+ + let [, setCounter] = useExternalState(counterState, false);
32
+
33
+ let handleClick = () => setCounter(0);
34
+
35
+ return <button onClick={handleClick}>×</button>;
36
+ };
37
+
38
+ let App = () => <><Counter/>{" "}<ResetButton/></>;
39
+ ```
40
+
41
+ ### Sharing state via Context
42
+
43
+ With SSR, it's common practice to put shared values into React Context rather than module-level variables to avoid cross-request data sharing. The same applies to external state. Provide external state to multiple components via React Context like any data in a React app:
44
+
45
+ ```diff
46
+ - let counterState = new State(0);
47
+ + let AppContext = createContext(new State(0));
48
+ ```
49
+
50
+ ```diff
51
+ - let [counter, setCounter] = useExternalState(counterState);
52
+ + let [counter, setCounter] = useExternalState(useContext(AppContext));
53
+ ```
54
+
55
+ ```jsx
56
+ let App = () => (
57
+ <AppContext.Provider value={new State(42)}>
58
+ <PlusButton/>{" "}<Display/>
59
+ </AppContext.Provider>
60
+ );
61
+ ```
62
+
63
+ ⬥ Like any data in a React app, the external state can also be split across multiple instances of `State` and multiple Contexts to maintain clearer semantic boundaries and more targeted data update subscriptions.
64
+
65
+ ⬥ Note that updating the `State` value doesn't change the instance's reference sitting in the React Context and therefore doesn't cause updates of the entire Context. Only the components subscribed to updates of the particular `State` instance by means of `useExternalState(state)` will be notified to re-render.
66
+
67
+ ### Filtering state updates
68
+
69
+ ⬥ Use the optional `false` parameter in `useExternalState(state, false)`, as in `<ResetButton>` above, to tell the hook not to subscribe the component to tracking the external state updates. The common use case for it is when a component makes use of the external state value setter without using the state value itself.
70
+
71
+ ⬥ Apart from setting the optional second parameter of `useExternalState(state, callback)` to a boolean value, use it as a render callback for more fine-grained control over component's re-renders in response to state changes:
72
+
73
+ ```js
74
+ let ItemCard = ({ id }) => {
75
+ let [items, setItems] = useExternalState(itemState, (render, { current, previous }) => {
76
+ // Assuming that the items have a `revision` property, re-render
77
+ // `ItemCard` only if the relevant item's `revision` has changed.
78
+ if (current[id].revision !== previous[id].revision) render();
79
+ });
80
+
81
+ // ...
82
+ };
83
+ ```
84
+
85
+ ### Integration with Immer
86
+
87
+ Immer can be used with state setters returned from `useExternalState()` just the same way as [with `useState()`](https://immerjs.github.io/immer/example-setstate#usestate--immer) to facilitate deeply nested data changes.
88
+
89
+ ### Persistence across page reloads
90
+
91
+ Replace `State` with `PersistentState` as shown below to get the state data synced to the specified `key` in `localStorage` and restored on page reload. After a persistent state is created, use it with `useExternalState(state)` the same way as `State` instances.
92
+
93
+ ```js
94
+ import { PersistentState } from "react-stateshape";
95
+
96
+ let counterState = new PersistentState(0, { key: "counter" });
97
+ ```
98
+
99
+ ⬥ Set `options.session` to `true` in `new PersistentState(value, options)` to use `sessionStorage`.
100
+
101
+ ⬥ Set `options.serialize()` and `options.deserialize()` to override the default data transform behavior, including filtering and rearranging the data (it's `JSON.stringify()` and `JSON.parse()` by default).
102
+
103
+ ⬥ Set up interaction with a custom storage by setting `{ read(), write(value)? }` as `options` in `new PersistentState(value, options)`.
104
+
105
+ ⬥ `PersistentState` skips interaction with the browser storage in non-browser environments, which makes it usable with SSR.
106
+
107
+ ## useRoute
108
+
109
+ Use this hook for URL-based rendering and SPA navigation, which boil down to accessing and changing the current URL treated as shared state under the hood.
110
+
111
+ ### URL-based rendering
112
+
113
+ URL-based rendering with `at(url, x, y)` shown below works similarly to conditional rendering with the ternary operator `atURL ? x : y`. It's equally applicable to props and components:
114
+
115
+ ```jsx
116
+ import { useRoute } from "react-stateshape";
117
+
118
+ let App = () => {
119
+ let { at } = useRoute();
120
+
121
+ return (
122
+ <header className={at("/", "full", "compact")}>
123
+ <h1>App</h1>
124
+ </header>
125
+ {at("/", <Intro/>)}
126
+ {at(/^\/sections\/(?<id>\d+)\/?$/, ({ params }) => <Section id={params.id}/>)}
127
+ );
128
+ };
129
+ ```
130
+
131
+ ⬥ `params` in dynamic values (as in `({ params }) => <Section id={params.id}/>` above) contains the URL pattern's capturing groups.
132
+
133
+ ⬥ By default, `useRoute` and the other routing hooks described here make use of the browser's URL, if it's available. Otherwise, use `<RouteProvider href="/x">` to set a specific URL value. Common use cases: SSR and tests. A less common use case: custom routing behavior, including custom non-URL-based routing ([example](https://codesandbox.io/p/sandbox/tykt44?file=%252Fsrc%252FApp.tsx)).
134
+
135
+ ⬥ See also the [Type-safe routes](#type-safe-routes) section.
136
+
137
+ ### SPA navigation
138
+
139
+ The SPA navigation API is largely aligned with the similar built-in APIs:
140
+
141
+ ```diff
142
+ + import { A, useRoute } from "react-stateshape";
143
+
144
+ let UserNav = ({ signedIn }) => {
145
+ + let { route } = useRoute();
146
+
147
+ let handleClick = () => {
148
+ - window.location.href = signedIn ? "/profile" : "/login";
149
+ + route.href = signedIn ? "/profile" : "/login";
150
+ };
151
+
152
+ return (
153
+ <nav>
154
+ - <a href="/">Home</a>
155
+ + <A href="/">Home</A>
156
+ <button onClick={handleClick}>Profile</button>
157
+ </nav>
158
+ );
159
+ };
160
+ ```
161
+
162
+ ⬥ `<A>` and `<Area>` are the two kinds of SPA route link components available out of the box. They have the same props and semantics as the corresponding HTML link elements `<a>` and `<area>`.
163
+
164
+ ⬥ The `route` object returned from `useRoute()` exposes an API resembling the built-in APIs of `window.location` and `history` carried over to SPA navigation: `.assign(url)`, `.replace(url)`, `.reload()`, `.href`, `.pathname`, `.search`, `.hash`, `.back()`, `.forward()`, `.go(delta)`.
165
+
166
+ ⬥ `route.navigate(options)` combines and extends `route.assign(url)` and `route.replace(url)` serving as a handy drop-in replacement for the similar `window.location` methods:
167
+
168
+ ```js
169
+ route.navigate({ href: "/intro", history: "replace", scroll: "off" });
170
+ ```
171
+
172
+ ⬥ Tweak link components by adding a relevant combination of the optional `data-` props corresponding to the `options` of `route.navigate(options)`:
173
+
174
+ ```jsx
175
+ <A href="/intro">Intro</A>
176
+ <A href="/intro" data-history="replace">Intro</A>
177
+ <A href="/intro" data-scroll="off">Intro</A>
178
+ <A href="/intro" data-spa="off">Intro</A>
179
+ ```
180
+
181
+ Using HTML link attributes (including the `data-` attributes) as SPA link component props makes link components easily interchangeable with HTML links and understandable without prior knowledge.
182
+
183
+ ⬥ Use the optional `callback` parameter of `useRoute(callback?)` for more fine-grained control over the component rendering in response to URL changes. This callback receives the `render` function as a parameter that should be called at some point. Use cases for this render callback include, for example, activating animated view transitions ([example](https://codesandbox.io/p/sandbox/w4q95n?file=%252Fsrc%252FApp.tsx)) or (less likely in regular circumstances) skipping re-renders for certain URL changes.
184
+
185
+ ## useNavigationStart / useNavigationComplete
186
+
187
+ These hooks set up optional actions to be done before and after a SPA navigation occurs respectively. Such intermediate actions are also known as routing middleware.
188
+
189
+ Some common examples of what can be handled with middleware include redirecting to another URL, preventing navigation with unsaved user input, setting the page title based on the current URL:
190
+
191
+ ```jsx
192
+ import { useNavigationComplete, useNavigationStart } from "react-stateshape";
193
+
194
+ function setTitle({ href }) {
195
+ document.title = href === "/intro" ? "Intro" : "App";
196
+ }
197
+
198
+ let App = () => {
199
+ let { route } = useRoute();
200
+ let [hasUnsavedChanges, setUnsavedChanges] = useState(false);
201
+
202
+ let handleNavigationStart = useCallback(({ href }) => {
203
+ if (hasUnsavedChanges)
204
+ return false; // Preventing navigation
205
+
206
+ if (href === "/") {
207
+ route.href = "/intro"; // SPA redirection
208
+ return false;
209
+ }
210
+ }, [hasUnsavedChanges, route]);
211
+
212
+ useNavigationStart(handleNavigationStart);
213
+ useNavigationComplete(setTitle);
214
+
215
+ // ...
216
+ };
217
+ ```
218
+
219
+ ⬥ The object parameter of the hooks' callbacks has the shape of the `route.navigate()`'s options, including `href` and `referrer`, the navigation destination and initial URLs.
220
+
221
+ ⬥ The callback of both hooks is first called when the component gets mounted if the route is already in the navigation-complete state.
222
+
223
+ ## useRouteState
224
+
225
+ Use this hook to manage URL parameters as state in a `useState`-like manner. Use the React's state mental model and migrate from local state without major code rewrites:
226
+
227
+ ```diff
228
+ + import { useRouteState } from "react-stateshape";
229
+
230
+ let App = () => {
231
+ - let [{ coords }, setState] = useState({ coords: { x: 0, y: 0 } });
232
+ + let [{ query }, setState] = useRouteState("/");
233
+
234
+ let setPosition = () => {
235
+ setState(state => ({
236
+ ...state,
237
+ - coords: {
238
+ + query: {
239
+ x: Math.random(),
240
+ y: Math.random(),
241
+ },
242
+ });
243
+ };
244
+
245
+ return (
246
+ <>
247
+ <h1>Shape</h1>
248
+ - <Shape x={coords.x} y={coords.y}/>
249
+ + <Shape x={query.x} y={query.y}/>
250
+ <p><button onClick={setPosition}>Move</button></p>
251
+ </>
252
+ );
253
+ };
254
+ ```
255
+
256
+ ⬥ `useRouteState(url, options?)` has an optional second parameter in the shape of the `route.navigate()`'s options. Pass `{ scroll: "off" }` as `options` to opt out from the default scroll-to-the-top behavior when the URL changes.
257
+
258
+ ⬥ See also the [Type-safe routes](#type-safe-routes) section.
259
+
260
+ ## Type-safe routes
261
+
262
+ When it comes to accessing parameters extracted from a URL pattern, by default the parameters are typed as `Record<string, string | undefined>`, which quite literally represents a map containing portions of a string URL.
263
+
264
+ ```tsx
265
+ let { at } = useRoute();
266
+
267
+ at(/^\/sections\/(?<id>\d+)\/?$/, ({ params }) => <Section id={params.id}/>)
268
+ // ^ Record<string, string | undefined>
269
+
270
+ let [state, setState] = useRouteState("/");
271
+ // ^ { query: Record<string, string | undefined> }
272
+ ```
273
+
274
+ Optionally, more specific type-aware parsing of URL parameters can be achieved by replacing string and RegExp URL patterns with URL patterns produced by a schema-based URL builder, like with `url-shape` and `zod` or a [similar tool](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec):
275
+
276
+ ```ts
277
+ import { createURLSchema } from "url-shape";
278
+ import { z } from "zod"; // Or another Standard Schema-compliant lib
279
+
280
+ // Get a type-aware URL builder `url()` based on a URL schema
281
+ export const { url } = createURLSchema({
282
+ "/sections/:id": z.object({
283
+ // URL path placeholder parameters
284
+ params: z.object({ id: z.coerce.number() }),
285
+ }),
286
+ "/": z.object({
287
+ // URL query (or search) parameters
288
+ query: z.optional(z.object({ x: z.coerce.number(), y: z.coerce.number() })),
289
+ }),
290
+ });
291
+ ```
292
+
293
+ The type-aware URL builder `url(pattern, options?)` provides hints about the types of the parsed URL parameters and helps avoid typos and type mismatches:
294
+
295
+ ```tsx
296
+ let { at } = useRoute();
297
+
298
+ at(url("/sections/:id"), ({ params }) => <Section id={params.id}/>)
299
+ // ^ { id: number }
300
+
301
+ let [state, setState] = useRouteState(url("/"));
302
+ // ^ { query: { x: number, y: number } | undefined }
303
+
304
+ <A href={url("/sections/:id", { id: 1 })}>Section 1</A>
305
+ // ^ { id: number }
306
+ ```
307
+
308
+ The URL schema as shown above doesn't have to cover the entire app. This approach allows for incremental or partial adoption of type-safe routing, where needed.
309
+
310
+ On the other hand, once the entire app is covered with type-safe routes, we might want to avoid future use of relaxed typing with string and RegExp URL patterns. This can be achieved by adding the following type declaration that effectively disallows string and RegExp URL patterns:
311
+
312
+ ```ts
313
+ declare module "react-stateshape" {
314
+ interface URLConfig {
315
+ strict: true;
316
+ }
317
+ }
318
+ ```
319
+
320
+ ### Nested routes
321
+
322
+ All routes are handled independently, so type-safe nested routes don't require special handling and don't maintain implicit relations with their parent routes. It also means that nested routes don't inherit their parent route parameters by default. Relations between routes (also beyond the direct inheritance of parameters) can be pretty straightforwardly defined on the URL schema level without imposing implicit constraints, which could be hard to work around.
323
+
324
+ ```ts
325
+ const sectionParams = z.object({
326
+ sectionId: z.coerce.number(),
327
+ });
328
+
329
+ export const { url } = createURLSchema({
330
+ "/sections/:sectionId": z.object({
331
+ params: sectionParams,
332
+ }),
333
+ "/sections/:sectionId/stories/:storyId": z.object({
334
+ params: z.object({
335
+ ...sectionParams.shape, // Shared URL parameters
336
+ storyId: z.coerce.number(),
337
+ }),
338
+ }),
339
+ });
340
+ ```
341
+
342
+ ## useTransientState
343
+
344
+ Use this hook to track an async action's state, whether it's pending, successfully completed, or failed, without affecting the application's data management.
345
+
346
+ In the example below, storing and rendering the essential app data (`items`) and the happy path scenario remain unaffected. The loading and error state handling works like a decoupled scaffolding to the main scenario. (`items` are stored in local state here, but any other state used by the app can be there instead.)
347
+
348
+ ```diff
349
+ + import { useTransientState } from "react-stateshape";
350
+ - import { fetchItems } from "./fetchItems.js";
351
+ + import { fetchItems as fetchItemsOriginal } from "./fetchItems.js";
352
+
353
+ export let ItemList = () => {
354
+ let [items, setItems] = useState([]);
355
+ + let [state, fetchItems] = useTransientState("items", fetchItemsOriginal);
356
+
357
+ useEffect(() => {
358
+ // The fetched items can be stored with any approach to app state
359
+ fetchItems().then(setItems);
360
+ }, [fetchItems]);
361
+
362
+ + if (state.initial || state.pending) return <p>Loading...</p>;
363
+ + if (state.error) return <p>An error occurred</p>;
364
+
365
+ return <ul>{items.map(/* ... */)}</ul>;
366
+ };
367
+ ```
368
+
369
+ ```diff
370
+ + import { useTransientState } from "react-stateshape";
371
+
372
+ - export let Status = ({ state }) => {
373
+ + export let Status = () => {
374
+ + let [state] = useTransientState("items");
375
+
376
+ if (state.initial) return null;
377
+ if (state.pending) return <>Busy</>;
378
+ if (state.error) return <>Error</>;
379
+
380
+ return <>OK</>;
381
+ };
382
+ ```
383
+
384
+ ### Shared and local async action state
385
+
386
+ Use a string key with `useTransientState(key, action?)` to access the same action state from multiple components (as in `ItemList` and `Status` above). Pass `null` as the key to have the action state scoped locally to the component where the hook is used.
387
+
388
+ ### Silent pending state
389
+
390
+ Use case: background actions or optimistic updates.
391
+
392
+ Set `{ silent: true }` as the last parameter of the trackable action returned from the `useTransientState` hook to prevent the `pending` property from switching to `true` in the pending state.
393
+
394
+ ```js
395
+ let [state, fetchItems] = useTransientState(fetchItemsOriginal);
396
+ // ^ `state.pending` remains `false` in the silent mode
397
+
398
+ fetchItems({ silent: true })
399
+ ```
400
+
401
+ ### Delayed pending state
402
+
403
+ Use case: Avoid flashing a process indicator when the action is likely to complete in a short while by delaying the pending state.
404
+
405
+ ```js
406
+ let [state, fetchItems] = useTransientState(fetchItemsOriginal);
407
+ // ^ `state.pending` remains `false` during the delay
408
+
409
+ fetchItems({ delay: 500 }) // in milliseconds
410
+ ```
411
+
412
+ ### Custom rejection handler
413
+
414
+ Allow the trackable action to reject explicitly with `{ throws: true }` as the last parameter, along with exposing `error` returned from `useTransientState` that goes by default.
415
+
416
+ ```js
417
+ fetchItems({ throws: true }).catch(handleError)
418
+ ```
419
+
420
+ ### Action state provider
421
+
422
+ `<TransientStateProvider>` creates an isolated instance of initial shared async action state. Its prime use cases are SSR and tests. It isn't required with client-side rendering, but it can be used to separate action states of larger self-contained portions of an app.
423
+
424
+ ```jsx
425
+ import { TransientStateProvider } from "react-stateshape";
426
+
427
+ <TransientStateProvider>
428
+ <App/>
429
+ </TransientStateProvider>
430
+ ```
431
+
432
+ Use the provider to set up a specific initial async action state when required:
433
+
434
+ ```jsx
435
+ let initialState = {
436
+ "fetch-items": { initial: false, pending: true },
437
+ };
438
+
439
+ <TransientStateProvider value={initialState}>
440
+ <App/>
441
+ </TransientStateProvider>
442
+ ```
443
+
444
+ ⬥ With an explicit value or without, the `<TransientStateProvider>`'s nested components will only respond to updates in the particular action state they subscribed to by means of `useTransientState`.
445
+
446
+ ## Annotated examples
447
+
448
+ Shared state
449
+
450
+ - [Shared state without Context](https://codesandbox.io/p/sandbox/gxkn85?file=%252Fsrc%252FApp.tsx), counter app, useExternalState
451
+ - [Shared state with Context](https://codesandbox.io/p/sandbox/9mpfsf?file=%252Fsrc%252FApp.tsx), counter app, useExternalState, React Context
452
+ - [Shared state with Immer](https://codesandbox.io/p/sandbox/gv4rgw?file=%252Fsrc%252FApp.tsx), counter app, useExternalState, Immer
453
+
454
+ Routing
455
+
456
+ - [URL-based rendering](https://codesandbox.io/p/sandbox/2nv8ck?file=%252Fsrc%252FApp.tsx), useRoute, link component
457
+ - [Type-safe URL-based rendering](https://codesandbox.io/p/sandbox/tltq5r?file=%252Fsrc%252FApp.tsx), useRoute, url-shape, zod
458
+ - [URL parameters as state](https://codesandbox.io/p/sandbox/6rp4sy?file=%252Fsrc%252FApp.tsx), useRouteState
459
+ - [Type-safe URL parameters as state](https://codesandbox.io/p/sandbox/6ck4qz?file=%252Fsrc%252FShapeSection.tsx), useRouteState, url-shape, zod
460
+ - [Type-safe nested routes](https://codesandbox.io/p/sandbox/pv9rgh?file=%252Fsrc%252FApp.tsx), useRoute, url-shape, zod
461
+ - [Unknown routes](https://codesandbox.io/p/sandbox/jnngqt?file=%252Fsrc%252FApp.tsx), useRoute
462
+ - [Lazy routes](https://codesandbox.io/p/sandbox/qw5r6g?file=%252Fsrc%252FApp.tsx), useRoute, React Suspense, React.lazy
463
+ - [View transitions](https://codesandbox.io/p/sandbox/w4q95n?file=%252Fsrc%252FApp.tsx), useRoute, View Transition API
464
+ - [Custom routing based on text input](https://codesandbox.io/p/sandbox/tykt44?file=%252Fsrc%252FApp.tsx), Route, RouteProvider, useRoute
465
+ - [Converting links in HTML content to SPA links](https://codesandbox.io/p/sandbox/7pfjc7?file=%252Fsrc%252FApp.tsx), useRouteLinks
466
+
467
+ Async action state
468
+
469
+ - [Shared async action state](https://codesandbox.io/p/sandbox/x9d2c9?file=%252Fsrc%252FItemList.tsx), useTransientState
470
+
471
+ Find also the code of these examples in the repo's [`tests`](https://github.com/axtk/react-stateshape/tree/main/tests) directory.
472
+
473
+ ## Internals
474
+
475
+ [`stateshape`](https://www.npmjs.com/package/stateshape)