|
| 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. |
0 commit comments