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 +21 -0
- package/README.md +475 -0
- package/dist/index.cjs +320 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.mjs +300 -0
- package/index.ts +24 -0
- package/package.json +49 -0
- package/src/A.tsx +12 -0
- package/src/Area.tsx +15 -0
- package/src/RouteContext.ts +6 -0
- package/src/RouteProvider.tsx +30 -0
- package/src/TransientStateContext.ts +7 -0
- package/src/TransientStateProvider.tsx +40 -0
- package/src/URLContext.ts +6 -0
- package/src/URLProvider.tsx +28 -0
- package/src/types/AProps.ts +6 -0
- package/src/types/AreaProps.ts +6 -0
- package/src/types/EnhanceHref.ts +8 -0
- package/src/types/LinkNavigationProps.ts +8 -0
- package/src/types/RenderCallback.ts +4 -0
- package/src/types/TransientState.ts +6 -0
- package/src/useExternalState.ts +76 -0
- package/src/useLinkClick.ts +29 -0
- package/src/useNavigationComplete.ts +11 -0
- package/src/useNavigationStart.ts +9 -0
- package/src/useRoute.ts +19 -0
- package/src/useRouteLinks.ts +22 -0
- package/src/useRouteState.ts +56 -0
- package/src/useTransientState.ts +191 -0
- package/src/useURL.ts +9 -0
- package/tsconfig.json +17 -0
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)
|