Skip to content

Commit 29a94ca

Browse files
authored
Added SyncExternalStore (#387)
1 parent 4eccbd1 commit 29a94ca

File tree

14 files changed

+713
-42
lines changed

14 files changed

+713
-42
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,6 @@ on:
88
branches: ["main"]
99

1010
jobs:
11-
back-compat-react-17:
12-
runs-on: ubuntu-latest
13-
14-
strategy:
15-
matrix:
16-
node-version: [18.x]
17-
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
18-
19-
steps:
20-
- name: Checkout
21-
uses: actions/checkout@v3
22-
23-
- name: Use Node.js ${{ matrix.node-version }}
24-
uses: actions/setup-node@v3
25-
with:
26-
node-version: ${{ matrix.node-version }}
27-
cache: "npm"
28-
29-
- name: Install
30-
run: |
31-
npm ci --ignore-scripts
32-
npm i -D -E @testing-library/react@12 @types/react@17
33-
34-
- name: Build
35-
run: npm run build --if-present
36-
37-
- name: Test
38-
run: npm test
39-
4011
build:
4112
runs-on: ubuntu-latest
4213

.vscode/launch.json

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,7 @@
44
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
55
"configurations": [
66
{
7-
"args": [
8-
"--runInBand",
9-
"--coverage",
10-
"false",
11-
"--testTimeout=99999999999999",
12-
"--findRelatedTests",
13-
"${relativeFile}"
14-
],
7+
"args": ["--runInBand", "--coverage", "false", "--testTimeout=0", "--findRelatedTests", "${relativeFile}"],
158
"console": "integratedTerminal",
169
"cwd": "${workspaceRoot}",
1710
"internalConsoleOptions": "neverOpen",

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- Added `ISyncExternalStore<T>` and `SyncExternalStore<T>` to make creating external stores for `React.useSyncExternalStore` easier (Requires React@18 or higher)
11+
1012
## [1.1.1] - 2022-10-13
1113

1214
- Widened React peer dependencies to include everything greater than 16

README.md

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# React context store
2+
3+
React storage helpers that provide structure around useSyncExternalStore or React Context.
4+
5+
For small to medium sized apps I found redux to be a bit heavy for what it achieved. In reality, most of the time I want to create a simple website that blocks the form submit as a network call is being processed.
6+
7+
## Sync External Store API
8+
9+
TL;DR: See [our working example](./src/sync-external-store/example/) of how to use our `SyncExternalStore<T>` base class with `useSyncExternalStore` and how to write cleaner tests with it.
10+
11+
React@18 added `useSyncExternalStore` which instead of storing any complicated state in a `useState`, allowed for the use of a non-React based store and created a simple interface to connect it to React. This means any complex logic of storing state (e.g. async fetch calls) can now get pulled out of React and into a vanilla JS file or package which means cleaner component code! However the main frustration with `useSyncExternalStore` is that you end up writing your `subscribe` logic over and over again. To avoid this duplication, this library provides a `SyncExternalStore<T>` base class to extend upon. Here's an example:
12+
13+
```ts
14+
import { SyncExternalStore } from "react-context-store";
15+
16+
type Item = {
17+
id: number;
18+
name: string;
19+
};
20+
type ItemStoreState = {
21+
state: "NOT_STARTED" | "PENDING" | "COMPLETE";
22+
data: { [id: string]: Item };
23+
};
24+
25+
class ItemStore extends SyncExternalStore<ItemStoreState> {
26+
constructor() {
27+
super({
28+
state: "NOT_STARTED",
29+
data: {},
30+
});
31+
}
32+
33+
getAll = async () => {
34+
this.updateSnapshot((prevSnapshot) => ({
35+
...prevSnapshot,
36+
state: "PENDING",
37+
}));
38+
39+
const response = await fetch("GET");
40+
const json = await response.json();
41+
42+
this.updateSnapshot((prevSnapshot) => ({
43+
...prevSnapshot,
44+
state: "COMPLETE",
45+
data: json,
46+
}));
47+
};
48+
}
49+
50+
export const itemStore = new ItemStore();
51+
```
52+
53+
Now to use this in a component, all you need is useSyncExternalStore:
54+
55+
```tsx
56+
import { itemStore } from "../stores/item-store.js";
57+
58+
export const Items = () => {
59+
const snapshot = React.useSyncExternalStore(...itemStore.getSyncExternalStoreParameters());
60+
const { state, data } = snapshot;
61+
62+
const onRefresh = () => {
63+
itemStore.getAll();
64+
};
65+
66+
return (
67+
<div>
68+
<h1>Items</h1>
69+
<ul>
70+
{Object.keys(data).map((key) => {
71+
const item = data[key];
72+
const { id, name } = item;
73+
return <li key={id}>{name}</li>;
74+
})}
75+
</ul>
76+
<button onClick={onRefresh} type="button">
77+
Refresh
78+
</button>
79+
</div>
80+
);
81+
};
82+
```
83+
84+
### Okay so what?
85+
86+
So why is this awesome? Because you can now get all the non-React state out of the React compnoents making the components more stateless than ever before! Further you can have a single store supplying data to multiple components without the heavy connecting of redux. All this means the component unit tests easier to write:
87+
88+
```tsx
89+
import { beforeEach, describe, it, jest } from "@jest/globals";
90+
import { act, render } from "@testing-library/react";
91+
import React from "react";
92+
93+
// 1. Mock the external store
94+
const getSnapshot = jest.fn();
95+
const getAll = jest.fn();
96+
jest.unstable_mockModule("../stores/item-store.js", () => {
97+
return {
98+
mockStore: {
99+
getAll,
100+
getSyncExternalStoreParameters: () => [
101+
// subscribe
102+
() => () => {},
103+
// snapshot
104+
getSnapshot,
105+
],
106+
},
107+
};
108+
});
109+
110+
// 2. Import the component that uses the external store
111+
const { Items } = await import("./items.js");
112+
113+
beforeEach(() => {
114+
jest.resetAllMocks();
115+
});
116+
117+
// 3. Test render of different snapshot data
118+
describe("rendering snapshot data", () => {
119+
// test only the rendering of the snapshot, no callbacks as the component can be entirely stateless now
120+
});
121+
122+
// 4. Test actions get triggered, not that the snapshot updated based off the action
123+
describe("callbacks to store", () => {
124+
// Even better, when doing callbacks that perform actions on the store, you don't need to test the entire
125+
// render cycle, just that the callback was sent with expected arguments. If you wanted to test
126+
// that the snapshot was rendered correctly, you'd be in step #3.
127+
});
128+
```
129+
130+
See [our working example](./src/sync-external-store/example/) for a running example of a store, component, and test
131+
132+
## Context Store API (deprecated)
133+
134+
There are a few types of store hooks both of which return the current store contents and the available modifiers.
135+
136+
- useContextStore - For non-indexable stores like objects and primatives or when you don't need to edit individual elements
137+
- useIndexableContextStore - For indexable stores like maps or arrays with a single loading state at the root
138+
- useIndexableStatefulContextStore - For indexable stores but when you want to maintain separate load states per item than the list of items
139+
140+
All come with some basic modifiers:
141+
142+
- useUpdateFactory - Used to modify all the values in the store at once. Useful for getAll, deleteAll, modifyAll, etc.
143+
- setContextData - Used to create custom modifiers if for some reason you don't like ours.
144+
145+
They also have the same states:
146+
147+
- unsent - No modifier has acted upon the data
148+
- loading - The modifier has been invoked but the action hasn't completed yet
149+
- success - The action was successful and the data has been updated
150+
- error - The action failed
151+
152+
The `useIndexableContextStore` and `useIndexableStatefulContextStore` have additional update factories that allow for creating functions that manipulate individual items:
153+
154+
- useCreateOneFactory - Used to create a new entry - GET and POST calls.
155+
- useDeleteOneFactory - Used to remove an entry - DELETE call
156+
- useUpdateOneFactory - Used to update an existing entry - PUT/PATCH calls
157+
158+
### Example usage
159+
160+
The following documentation has been copied out of a test case. If you find that it's not working, please check the test case. This example is an array but you can also use maps, or store a non-indexable type.
161+
162+
First create the context store
163+
164+
```javascript
165+
import React, { PropsWithChildren } from "react";
166+
167+
import {
168+
ContextStore,
169+
getNotImplementedPromise,
170+
useIndexableContextStore,
171+
} from "react-context-store";
172+
173+
export type Item = {
174+
id: number,
175+
name: string,
176+
};
177+
178+
type ContextStoreData = Array<Item>;
179+
180+
export type RefreshAllParams = void;
181+
export interface ContextValue extends ContextStore<ContextStoreData> {
182+
refreshAll: (params: RefreshAllParams) => Promise<ContextStoreData>;
183+
}
184+
185+
const defaultValue: ContextValue = {
186+
data: [],
187+
refreshAll: getNotImplementedPromise,
188+
state: "unsent",
189+
};
190+
191+
export const Context = React.createContext(defaultValue);
192+
export type ProviderProps = PropsWithChildren<Record<string, never>>;
193+
194+
export function ApiProvider(props: ProviderProps) {
195+
const { children } = props;
196+
const [contextValue, { useUpdateFactory }] = useIndexableContextStore(
197+
defaultValue
198+
);
199+
200+
const refreshAll = useUpdateFactory({
201+
action: (params: RefreshAllParams) => {
202+
return fetchResults(params);
203+
},
204+
});
205+
206+
return (
207+
<Context.Provider
208+
value={{
209+
...contextValue,
210+
refreshAll,
211+
}}
212+
>
213+
{children}
214+
</Context.Provider>
215+
);
216+
}
217+
```
218+
219+
Somewhere in your app, setup the shared provider
220+
221+
```javascript
222+
import { ApiProvider, Context } from "../context";
223+
import { List } from "./component";
224+
225+
export function App() {
226+
return (
227+
<ApiProvider>
228+
<List />
229+
</ApiProvider>
230+
);
231+
}
232+
```
233+
234+
And finally consume the context
235+
236+
```javascript
237+
import { ApiProvider, Context } from "../context";
238+
239+
export function List() {
240+
const { data, refreshAll, state } = useContext(Context);
241+
242+
useEffect(() => {
243+
refreshAll();
244+
});
245+
246+
switch (state) {
247+
case "error":
248+
return <div>Oh no!</div>;
249+
case "success":
250+
return (
251+
<div>
252+
<ul>
253+
{data.map((item) => {
254+
const { id, name } = item;
255+
return <li key={id}>{name}</li>;
256+
})}
257+
</ul>
258+
</div>
259+
);
260+
default:
261+
return <div>Loading</div>;
262+
}
263+
}
264+
```
265+
266+
For more examples, take a look at our extensive testing suite.

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"author": "Snowcoders",
3-
"description": "A two way binding solution that uses React context as a storage mechanism.",
3+
"description": "React storage helpers that provide structure around useSyncExternalStore or React Context.",
44
"devDependencies": {
55
"@release-it/keep-a-changelog": "4.0.0",
66
"@snowcoders/renovate-config": "3.0.0-beta.13",
@@ -40,6 +40,13 @@
4040
"./package.json": "./package.json"
4141
},
4242
"homepage": "https://github.com/snowcoders/react-context-store",
43+
"keywords": [
44+
"react",
45+
"useSyncExternalStore",
46+
"SyncExternalStore",
47+
"context",
48+
"store"
49+
],
4350
"license": "MIT",
4451
"main": "./dist-cjs/index.js",
4552
"name": "react-context-store",

0 commit comments

Comments
 (0)