├── .dockerignore ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── Changelog.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── _removed.md ├── assets │ └── async-flow.png ├── async-action-use.md ├── async-actions-creating.md ├── async-actions-introduction.md ├── async-actions-other-options.md ├── async-cache-break-hook.md ├── async-cache-clearing.md ├── async-hooks-overview.md ├── async-post-action-hook.md ├── async-server-rendering.md ├── async-short-circuit-hook.md ├── inject-store-state.md ├── installation.md ├── quick-example-server-rendered.md ├── quick-example.md ├── reactions.md ├── redux-dev-tools.md ├── subscribe.md ├── update-store.md └── use-store-state-hook.md ├── graphics ├── Icon on Light.png ├── Icon.png ├── logo-new.png ├── logo-newest.png ├── logo.png └── pullstate-logo.png ├── package.json ├── rollup.config.js ├── src ├── InjectAsyncAction.ts ├── InjectStoreState.ts ├── InjectStoreStateOpt.ts ├── PullstateCore.tsx ├── Store.ts ├── async-types.ts ├── async.ts ├── batch.ts ├── fastDeepEqual.d.ts ├── globalClientState.ts ├── index.ts ├── reduxDevtools.ts ├── useLocalStore.ts ├── useStoreState.ts ├── useStoreStateOpt-types.ts └── useStoreStateOpt.ts ├── test ├── benchmark │ ├── BenchmarkUtils.ts │ ├── all.ts │ ├── benchmark-all-immer.ts │ ├── benchmark-async-argument.ts │ ├── benchmark-destructuring.ts │ ├── benchmark-immer-with-stores.ts │ ├── benchmark-immer-without-stores.ts │ └── keyFromObjectImplementations.ts ├── dist-types │ └── TestDistTypes.ts ├── jest.config.js ├── old_jest.config.js ├── package.json ├── rtl.setup.ts ├── test.umd.html ├── tests │ ├── TestSetup.ts │ ├── TestUtils.ts │ ├── __snapshots__ │ │ ├── async.tests.tsx.snap │ │ ├── client.tests.tsx.snap │ │ └── ssr.tests.tsx.snap │ ├── async.tests.tsx │ ├── basic-store.tests.tsx │ ├── client.tests.tsx │ ├── core.tests.tsx │ ├── opt-state-listeners.tests.tsx │ ├── ssr.async.tests.tsx │ ├── ssr.tests.tsx │ └── testStores │ │ └── TestUIStore.ts ├── tsconfig.json └── yarn.lock ├── tsconfig.json ├── typedoc.json ├── typedoc.tsconfig.json ├── website ├── README.md ├── core │ └── Footer.js ├── i18n │ └── en.json ├── package.json ├── pages │ └── en │ │ └── index.js ├── sidebars.json ├── siteConfig.js ├── static │ ├── css │ │ └── custom.css │ └── img │ │ ├── async-flow.png │ │ ├── docusaurus.svg │ │ ├── favicon.png │ │ ├── favicon │ │ └── favicon.ico │ │ ├── icon-transparent-ondark-new.png │ │ ├── icon-transparent-ondark.png │ │ ├── icon-transparent-onlight-new.png │ │ ├── icon-transparent-onlight.png │ │ ├── logo-new.png │ │ ├── logo-newest.png │ │ ├── logo-ondark-small.png │ │ └── oss_logo.png └── yarn.lock └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [lostpebble] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | dist 4 | yarn-error.log 5 | .rpt2_cache 6 | website/build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5" 4 | } -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # See release tags for new changelog updates 2 | 3 | --- 4 | 5 | ## 1.15.0 6 | 7 | Added integration with Redux Devtools. Make use of `registerInDevtools(Stores)` to use it. Argument is an object of `{ [storeName: string]: Store }` - which will register your stores instanced according to the name provided. 8 | 9 | ### 1.13.2 10 | 11 | More fixes for run() when using `respectCache: true`. Prevents the cacheBreakHook from clearing the current async state, even if the action hasn't finished yet. 12 | 13 | ### 1.13.1 14 | 15 | Some fixes for run() when using `respectCache: true` that makes a second run wait for the result to any currently running action. 16 | 17 | A minor leak fix for `read()`. 18 | 19 | Much thanks to https://github.com/schummar for the fixes! 20 | 21 | ## 1.13.0 22 | 23 | Added in `createAsyncActionDirect()` - which allows you to simply directly wrap promises instead of using `successResult()` or `errorResult()` methods implicitly. Useful for scenarios that don't require such verbosity. It will directly return a `successResult()` - or otherwise, if an error is thrown, an `errorResult()`. 24 | 25 | Allow `use()` of an async action to be executed on the same arguments later on in the component, with the returned method `execute()`. 26 | 27 | ## 1.12.0 28 | 29 | Some TypeScript type updates - hopefully fixing some `strict: true` issues people were having. 30 | 31 | ### 1.11.3 32 | 33 | Patch fix for `run()` and `beckon()` with an action making use of cache hook in the same component causing infinite loops. 34 | 35 | ## 1.11.0 36 | 37 | Added `AsyncActionName.use(args, options)` - a new way to make use of your Async Actions. By default it acts just like `useBeckon()`, except it returns an object instead of an array. 38 | 39 | This returned object now includes more helpful flags, and is shaped like so: 40 | 41 | ```ts 42 | { 43 | isLoading: boolean; 44 | isFinished: boolean; 45 | isUpdating: boolean; 46 | isStarted: boolean; 47 | error: boolean; 48 | endTags: string[]; 49 | message: string; 50 | payload: R; 51 | renderPayload: ((payload: R) => any) => any; 52 | } 53 | ``` 54 | 55 | If you want `use()` to act like `useWatch()` (i.e. not initiating the action when the hook is first called), then pass in an options object as the second argument, containing `initiate: false`. 56 | 57 | `renderPayload` is a very useful function. You can use this in your React component to conditionally render stuff only when your action payload has returned successfully. You can use it like so: 58 | 59 | ```typescript jsx 60 | const userAction = LoadUserAction.use({ id: userId }); 61 | 62 | return ( 63 |
64 | {userAction.renderPayload((user) => ( 65 | User Name: {user.name} 66 | ))} 67 |
68 | ); 69 | ``` 70 | 71 | The inner `` there will not render if our action hasn't resolved successfully. 72 | 73 | #### 1.10.5 74 | 75 | Imported modules `immer` and `fast-deep-equal` using ES6 Modules which might help with tree-shaking and bundling in consumer projects. 76 | 77 | #### 1.10.4 78 | 79 | Exported a bunch of TypeScript types (mostly Async stuff) for easier extending of library. 80 | 81 | #### 1.10.3 82 | 83 | Bugfix for when passing dependencies to `useStoreState` as a third argument. Should never re-vert to the previously set state now. 84 | 85 | Fixed Hot Reloading while using `useStoreState` by ensuring that registering of the listener is done within the `useEffect()` hook. 86 | 87 | #### 1.10.2 88 | 89 | Bugfix for `dormant` setting which wasn't re-triggering cached results when switching between dormant and an old cached value. 90 | 91 | #### 1.10.1 92 | 93 | Minor changes for https://github.com/lostpebble/pullstate/issues/25 94 | 95 | Added the ability to run `postActionHook` after doing a cache update with `AsyncAction.updateCache()` 96 | 97 | ### 1.10.0 98 | 99 | Updated `immer` to `^5.0.0` which has better support for `Map` and `Set`. 100 | 101 | Updated `fast-deep-equal` to use `^3.0.0`, which has support for `Map` and `Set` types, allowing you to use these without worry in your stores. 102 | 103 | ## 1.9.0 104 | 105 | - Added the ability to pass a third option to `useStoreState()` - this allows the our listener to be dynamically updated to listen to a different sub-state of our store. Similar to how the last argument in `useEffect()` and such work. 106 | - see https://github.com/lostpebble/pullstate/issues/22 107 | 108 | #### React Suspense! 109 | 110 | - You can now use Async Actions with React Suspense. Simply use them with the format: `myAction.read(args)` inside a component which is inside of ``. 111 | 112 | ## 1.8.0 113 | 114 | - Added the passable option `{ dormant: true }` to Async Function's `useBeckon()` or `useWatch()`, which will basically just make the action completely dormant - no execution or hitting of cache or anything, but will still respect the option `{ holdPrevious: true }`, returning the last completed result for this action if it exists. 115 | 116 | ### 1.7.3 117 | 118 | [TypeScript] Minor type updates for calling `useStore()` directly on one of your stores, so that the "sub-state" function gets the store's state interface correctly. 119 | 120 | ### 1.7.2 121 | 122 | Minor quality of life update, able to now set successful cached payloads directly in the cache using 123 | 124 | ``` 125 | setCachedPayload(args, payload, options) 126 | ``` 127 | 128 | Much thanks again to @bitttttten for the pull request. 129 | 130 | ### 1.7.1 131 | 132 | Slight change to how `Store.update()` runs when accepting an array of updaters. It now runs each update separately on the state, allowing for updates further down the line to act on previous updates (still triggers re-renders of your React components as if it were a single update). 133 | 134 | Thanks to @bitttttten for a fix which allows passing no arguments when using `createPullstateCore()`. 135 | 136 | ## 1.7.0 137 | 138 | Allow optional passing of multiple update functions to `Store.update()` in the format of an array. 139 | 140 | For example: 141 | 142 | ```ts 143 | UIStore.update([setDarkMode, setTypography("Roboto)]); 144 | ``` 145 | 146 | This allows "batching" actions which are defined in smaller, modular functions together in one go. It makes the pattern of creating "updater" functions for your store a little more easier to work with, as you can combine multiples of them in one update now. 147 | 148 | ### 1.6.4 149 | 150 | - Exported `TUpdateFunction` so that we can more easily create update functions corresponding to pullstate. 151 | 152 | ### 1.6.3 153 | 154 | - Added convenience method `useInstance` or `PullstateCore.useInstance` (for better typing on your stores) - which gives direct access to your Pullstate instance which was passed in to `PullstateProvider`. 155 | 156 | ### 1.6.2 157 | 158 | - Added export for `PullstateContext` for more customized usage of Pullstate. 159 | 160 | ### 1.6.1 161 | 162 | - **[async][bugfix]** fixed problem with multiple beckoned actions infinite looping for same arguments 163 | - Allow for passing a non-object as an argument to an async action (`string` / `boolean` etc.) 164 | 165 | ## 1.6.0 166 | 167 | Added the ability to hold onto previously resolved action results (if they were successful) until the new action resolves, when using a `useWatch()` or `useBeckon()`: 168 | 169 | - Pass `holdPrevious: true` as an option to either `useWatch()` or `useBeckon()` to enable this. 170 | - When a new action is running on top of an old result, the returned value from your action hooks will now have `started = true`, `finished = true`, `result = sameResult` and a final value to check called `updating = true`: 171 | - `[true, true, result, true]` (for `useWatch()`) 172 | - `[true, result, true]` for (`useBeckon()`) 173 | 174 | ### 1.5.1 175 | 176 | Added `--strictNullChecks` in TypeScript and fixed loads of types which had undefined / null options. Should let Pullstate play nicely with the other children now. 177 | 178 | ## 1.5.0 179 | 180 | Allow selecting a subset of passed arguments too an async function to create the fingerprint. This is purely for performance reasons when you want to pass in large data sets. 181 | 182 | Pass an extra option when creating the Async Action: `subsetKey: (args) => subset` - basically it takes the arguments given, and allows you to return subset of those arguments which pullstate will use internally to create cache fingerprints. 183 | 184 | ### 1.4.1 185 | 186 | - Added `immer` as direct dependency. Was `peerDependency` before - but this is not sufficient when requiring certain versions of `immer` for new functionality. Also `peerDependency` gives errors to users whose projects don't use `immer` outside of `pullstate`. 187 | 188 | ## 1.4.0 189 | 190 | - Added the ability to listen for change patches on an entire store, using `Store.listenToPatches(patchListener)`. 191 | 192 | - Fixed a bug where applying patches to stores didn't trigger the new optimized updates. 193 | - Fixed bug with Reactions running twice 194 | 195 | ### 1.3.1 196 | 197 | - Fixed Reactions to work with path change optimizations (see `1.2.0`). Previously only `update()` kept track of path changes - forgot to add path tracking to Reactions. 198 | 199 | ## 1.3.0 200 | 201 | - Expanded on `getCached()`, `setCached()` and `updateCached()` on Async Actions - and made sure they can optionally notify any listeners on their cached values to re-render on changes. 202 | - Added `clearAllUnwatchedCache()` on Async Actions for quick and easy garbage collection. 203 | - Added `timeCached` as a passed argument to the `cacheBreakHook()`, allowing for easier cache invalidation against the time the value was last cached. 204 | 205 | ## 1.2.0 206 | 207 | New experimental optimized updates (uses immer patches internally). To use, your state selections need to be made using paths - and make use of the new methods and components `useStoreStateOpt` and `` respectively. 208 | 209 | Instead of passing a function, you now pass an array of path selections. The state returned will be an array of values per each state selection path. E.g: 210 | 211 | ```ts 212 | const [isDarkMode] = useStoreStateOpt(UIStore, [["isDarkMode"]]); 213 | ``` 214 | 215 | The performance benefits stem from Pullstate not having to run equality checks on the results of your selected state and then re-render your component accordingly, but instead looks at the immer update patches directly for which paths changed in your state and re-renders the listeners on those paths. 216 | 217 | ## 1.1.0 218 | 219 | Fixed issue with postActionHook not being called on the server for Async Actions. 220 | 221 | Added the following methods on Async Actions: 222 | 223 | - `setCached()` 224 | - `updateCached()` 225 | 226 | For a more finer-grained control of async action cache. 227 | 228 | `updateCached()` functions exactly the same as `update()` on stores, except it only runs on a previously successfully returned cached value. If nothing is cached, nothing is run. 229 | 230 | ## 1.0.0-beta.7 231 | 232 | Replaced `shallowEqual` from `fbjs` with the tiny package `fast-deep-equal` for object comparisons in various parts of the lib. 233 | 234 | ## 1.0.0-beta.6 235 | 236 | Fixed the `postActionHook` to work correctly when hitting a cached value. 237 | 238 | ## 0.8.0.alpha-2 239 | 240 | Added `IPullstateInstanceConsumable` as an export to help people who want to create code using the Pullstate stores' instance. 241 | 242 | ## 0.8.0.alpha-1 243 | 244 | Some refactoring of the Async Actions and adding of hooks for much finer grained control: 245 | 246 | `shortCicuitHook()`: Run checks to resolve the action with a response before it even sets out. 247 | 248 | `breakCacheHook()`: When an action's state is being returned from the cache, this hook allows you to run checks on the current cache and your stores to decide whether this action should be run again (essentially flushing / breaking the cache). 249 | 250 | `postActionHook()`: This hook allows you to run some things after the action has resolved, and most importantly allows code to run after each time we hit the cached result of this action as well. This is very useful for interface changes which need to change / update outside of the action code. 251 | 252 | `postActionHook()` is run with a context variable which tells you in which context it was run, one of: CACHE, SHORT_CIRCUIT, DIRECT_RUN 253 | 254 | These hooks should hopefully allow even more boilerplate code to be eliminated while working in asynchronous state scenarios. 255 | 256 | ## 0.7.1 257 | 258 | - Made the `isResolved()` function safe from causing infinite loops (Async Action resolves, but the state of the store still makes `isResolved()` return false which causes a re-trigger when re-rendering - most likely happens when not checking for error states in `isResolved()`) - instead posting an error message to the console informing about the loop which needs to be fixed. 259 | 260 | ## 0.7.0 261 | 262 | **:warning: Replaced with async action hooks above in 0.8.0** 263 | 264 | Added the options of setting an `isResolve()` synchronous checking function on Async Actions. This allows for early escape hatching (we don't need to run this async action based on the current state) and cache busting (even though we ran this Async Action before and we have a cached result, the current state indicates we need to run it again). 265 | 266 | You can set it like so: 267 | 268 | ```typescript jsx 269 | const loadEntity = PullstateCore.createAsyncAction<{ id: string }>( 270 | async ({ id }, { EntityStore }) => { 271 | const resp = await endpoints.getEntity({ id }); 272 | 273 | if (resp.positive) { 274 | EntityStore.update((s) => { 275 | s.viewingEntity = resp.payload; 276 | }); 277 | return successResult(); 278 | } 279 | 280 | return errorResult(resp.endTags, resp.endMessage); 281 | }, 282 | 283 | // This second argument is the isResolved() function 284 | 285 | ({ id }, { EntityStore }) => { 286 | const { viewingEntity } = EntityStore.getRawState(); 287 | 288 | if (viewingEntity !== null && viewingEntity.id === id) { 289 | return successResult(); 290 | } 291 | 292 | return false; 293 | } 294 | ); 295 | ``` 296 | 297 | It has the same form as the regular Async Action function, injecting the arguments and the stores - but needs to return a synchronous result of either `false` or the expected end result (as if this function would have run asynchronously). 298 | 299 | ## 0.6.0 300 | 301 | - Added "reactions" to store state. Usable like so: 302 | 303 | ```typescript jsx 304 | UIStore.createReaction( 305 | (s) => s.valueToListenForChanges, 306 | (draft, original, watched) => { 307 | // do something here when s.valueToListenForChanges changes 308 | // alter draft as usual - like regular update() 309 | // watched = the value returned from the first function (the selector for what to watch) 310 | } 311 | ); 312 | ``` 313 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.11.4 2 | 3 | WORKDIR /app/website 4 | 5 | EXPOSE 3000 35729 6 | COPY ./docs /app/docs 7 | COPY ./website /app/website 8 | RUN yarn install 9 | 10 | CMD ["yarn", "start"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Paul Myburgh and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Pullstate 3 |

4 | 5 | ### pullstate 6 | 7 | > Ridiculously simple state stores with performant retrieval anywhere 8 | > in your React tree using the wonderful concept of React hooks! 9 | 10 | * ~7KB minified and gzipped! (excluding Immer and React) 11 | * Built with Typescript, providing a great dev experience if you're using it too 12 | * Uses [immer](https://github.com/mweststrate/immer) for state updates - easily and safely mutate your state directly! 13 | * **NEW** - [Create async actions](https://lostpebble.github.io/pullstate/docs/async-actions-introduction) and use React hooks or `` to have complete control over their UI states! 14 | 15 | _Originally inspired by the now seemingly abandoned library - [bey](https://github.com/jamiebuilds/bey). Although substantially 16 | different now- with Server-side rendering and Async Actions built in! Bey was in turn inspired by 17 | [react-copy-write](https://github.com/aweary/react-copy-write)._ 18 | 19 | Try out a quick example: 20 | 21 | [![Edit Pullstate Client-only Example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/myvj8zzypp) 22 | 23 | ### 🎉 **[New documentation site is live!](https://lostpebble.github.io/pullstate/)** 24 | 25 | * [Installation](https://lostpebble.github.io/pullstate/docs/installation) 26 | * [Quick example](https://lostpebble.github.io/pullstate/docs/quick-example) 27 | * [Quick example - Server rendering](https://lostpebble.github.io/pullstate/docs/quick-example-server-rendered) 28 | * [Async Actions](https://lostpebble.github.io/pullstate/docs/async-actions-introduction) 29 | * [Creation](https://lostpebble.github.io/pullstate/docs/async-actions-creating) 30 | * [Usage](https://lostpebble.github.io/pullstate/docs/async-action-use) 31 | * [Async action hooks](https://lostpebble.github.io/pullstate/docs/async-hooks-overview) 32 | 33 | --- 34 | 35 | # **Let's dive right in** 36 | 37 | > This is taken directly from [the documentation site](https://lostpebble.github.io/pullstate/docs/quick-example), to give you a quick overview of Pullstate here on github. Be sure to check out the site to learn more. 38 | 39 | To start off, install `pullstate`. 40 | 41 | ```bash 42 | yarn add pullstate 43 | ``` 44 | 45 | ## Create a store 46 | 47 | Define the first **state store**, by passing an initial state to `new Store()`: 48 | 49 | 50 | ```jsx 51 | import { Store } from "pullstate"; 52 | 53 | export const UIStore = new Store({ 54 | isDarkMode: true, 55 | }); 56 | ``` 57 | 58 | ## Read our store's state 59 | 60 | Then, in React, we can start using the state of that store using a simple hook `useState()`: 61 | 62 | ```tsx 63 | import * as React from "react"; 64 | import { UIStore } from "./UIStore"; 65 | 66 | export const App = () => { 67 | const isDarkMode = UIStore.useState(s => s.isDarkMode); 68 | 69 | return ( 70 |
75 |

Hello Pullstate

76 |
77 | ); 78 | }; 79 | ``` 80 | 81 | The argument to `useState()` over here (`s => s.isDarkMode`), is a selection function that ensures we select only the state that we actually need for this component. This is a big performance booster, as we only listen for changes (and if changed, re-render the component) on the exact returned values - in this case, simply the value of `isDarkMode`. 82 | 83 | --- 84 | 85 | ## Add interaction (update state) 86 | 87 | Great, so we are able to pull our state from `UIStore` into our App. Now lets add some basic interaction with a ` 105 | 106 | ); 107 | ``` 108 | 109 | Notice how we call `update()` on `UIStore`, inside which we directly mutate the store's state. This is all thanks to the power of `immer`, which you can check out [here](https://github.com/immerjs/immer). 110 | 111 | Another pattern, which helps to illustrate this further, would be to actually define the action of toggling dark mode to a function on its own: 112 | 113 | 114 | ```tsx 115 | function toggleMode(s) { 116 | s.isDarkMode = !s.isDarkMode; 117 | } 118 | 119 | // ...in our 121 | ``` 122 | 123 | Basically, to update our app's state all we need to do is create a function (inline arrow function or regular) which takes the current store's state and mutates it to whatever we'd like the next state to be. 124 | 125 | ## Omnipresent state updating 126 | 127 | Something interesting to notice at this point is that we are just importing `UIStore` directly and running `update()` on it: 128 | 129 | ```tsx 130 | import { UIStore } from "./UIStore"; 131 | 132 | // ...in our 134 | ``` 135 | 136 | And our components are being updated accordingly. We have freed our app's state from the confines of the component! This is one of the main advantages of Pullstate - allowing us to separate our state concerns from being locked in at the component level and manage things easily at a more global level from which our components listen and react (through our `useStoreState()` hooks). 137 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | docusaurus: 5 | build: . 6 | ports: 7 | - 3000:3000 8 | - 35729:35729 9 | volumes: 10 | - ./docs:/app/docs 11 | - ./website/blog:/app/website/blog 12 | - ./website/core:/app/website/core 13 | - ./website/i18n:/app/website/i18n 14 | - ./website/pages:/app/website/pages 15 | - ./website/static:/app/website/static 16 | - ./website/sidebars.json:/app/website/sidebars.json 17 | - ./website/siteConfig.js:/app/website/siteConfig.js 18 | working_dir: /app/website 19 | -------------------------------------------------------------------------------- /docs/_removed.md: -------------------------------------------------------------------------------- 1 | 2 | ```tsx 3 | // UIStore.ts 4 | import { Store } from "pullstate"; 5 | 6 | interface IUIStore { 7 | isDarkMode: boolean; 8 | } 9 | 10 | export const UIStore = new Store({ 11 | isDarkMode: true, 12 | }); 13 | ``` 14 | 15 | Server-rendering requires that we create a central place to reference all our stores, and we do this using `createPullstateCore()`: 16 | 17 | ```tsx 18 | // PullstateCore.ts 19 | import { UIStore } from "./stores/UIStore"; 20 | import { createPullstateCore } from "pullstate"; 21 | 22 | export const PullstateCore = createPullstateCore({ 23 | UIStore 24 | }); 25 | 26 | ``` 27 | 28 | --- 29 | 30 | For example: 31 | 32 | ```tsx 33 | // a useEffect() hook in our functional component 34 | 35 | useEffect(() => { 36 | const tileLayer = L.tileLayer(tileTemplate.url, { 37 | minZoom: 3, 38 | maxZoom: 18, 39 | }).addTo(mapRef.current); 40 | 41 | const unsubscribeFromTileTemplate = GISStore.createReaction( 42 | s => s.tileLayerTemplate, 43 | newTemplate => { 44 | tileLayer.setUrl(newTemplate.url); 45 | } 46 | ); 47 | 48 | return () => { 49 | unsubscribeFromTileTemplate(); 50 | }; 51 | }, []); 52 | ``` 53 | 54 | As you can see we receive a function back from `createReaction()` which we have used here in the "cleanup" return function of `useEffect()` to unsubscribe from this reaction. 55 | -------------------------------------------------------------------------------- /docs/assets/async-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/docs/assets/async-flow.png -------------------------------------------------------------------------------- /docs/async-action-use.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-action-use 3 | title: Ways to make use of Async Actions 4 | sidebar_label: Use Async Actions 5 | --- 6 | 7 | *For the sake of being complete in our examples, all possible return states are shown - in real application usage, you might only use a subset of these values.* 8 | 9 | **All examples make use of the previously created Async Action `searchPicturesForTag()`, you can [see more in action creation](async-actions-creating.md).** 10 | 11 | ## Watch an Async Action (React hook) 12 | 13 | ```ts 14 | const [started, finished, result, updating] = searchPicturesForTag.useWatch({ tag }, options); 15 | ``` 16 | 17 | * This **React hook** "watches" the action. By watching we mean that we are not initiating this action, but only listening for when this action actually starts through some other means (tracked with `started` here), and then all its states after. 18 | * Possible action states (if `true`): 19 | * `started` : This action has begun its execution. 20 | * `finished`: This action has finished 21 | * `updating`: This is a special action state which can be instigated through `run()`, or when an update triggers and we had passed the option `holdPrevious: true`, which we will see further down. 22 | * `result` is the structured result object you return from your action ([see more in action creation](async-actions-creating.md#what-to-return-from-an-action)). 23 | 24 | `watch()` also takes an options object as the second argument. 25 | 26 | #### Options 27 | 28 | ```ts 29 | { 30 | postActionEnabled?: boolean; 31 | cacheBreakEnabled?: boolean; 32 | holdPrevious?: boolean; 33 | dormant?: boolean; 34 | } 35 | ``` 36 | 37 | _(Explained in next paragraph)_ 38 | 39 | ## Beckon an Async Action (React hook) 40 | 41 | ```tsx 42 | const [finished, result, updating] = searchPicturesForTag.useBeckon({ tag }, options); 43 | ``` 44 | 45 | * Exactly the same as `useWatch()` above, except this time we instigate this action when this hook is first called. 46 | 47 | * Same action states, except for `started` since we are starting this action by default 48 | 49 | `beckon()` also takes an options object as the second argument. 50 | 51 | #### Options 52 | 53 | ```ts 54 | { 55 | postActionEnabled?: boolean; 56 | cacheBreakEnabled?: boolean; 57 | holdPrevious?: boolean; 58 | dormant?: boolean; 59 | ssr?: boolean; 60 | } 61 | ``` 62 | 63 | * You can disable the `postActionHook` and / or `cacheBreakHook` for this interaction with this action by using the options here. See more about [`hooks`](async-hooks-overview.md). 64 | 65 | * `holdPrevious` is a special option that allows the result value from this calling of the Async Action to remain in place while we are currently executing the next set of arguments. (e.g. still displaying the previous search results while the system is querying for the next set) 66 | 67 | * `dormant` is a way by which you can basically make Async Actions conditional. If `dormant = true`, then this action will not listen / execute at all. 68 | 69 | ### Ignore `beckon()` for server-rendering 70 | 71 | * If you are server rendering and you would _not_ like a certain Async Action to be instigated on the server (i.e. you are fine with the action resolving itself client-side only), you can pass as an option to beckon `{ ssr: false }`. 72 | 73 | ## (React Suspense) Read an Async Action 74 | 75 | *You can read more about React Suspense on the [React website](https://reactjs.org/docs/concurrent-mode-suspense.html)* 76 | 77 | ```tsx 78 | const PicturesDisplay = ({ tag }) => { 79 | const pictures = searchPicturesForTag.read({ tag }); 80 | 81 | // make use of the pictures data here as if it was regular, loaded state 82 | } 83 | 84 | const PicturesPage = () => { 85 | return ( 86 | Loading Pictures....}> 87 | 88 | 89 | ); 90 | } 91 | ``` 92 | 93 | You can pass the following options to `read(args, options)`: 94 | 95 | ```ts 96 | interface Options { 97 | postActionEnabled?: boolean; 98 | cacheBreakEnabled?: boolean; 99 | } 100 | ``` 101 | 102 | ## Run an Async Action directly 103 | 104 | ```tsx 105 | const result = await searchPicturesForTag.run({ tag }); 106 | ``` 107 | 108 | * Run's the async action directly, just like a regular promise. Any actions that are currently being watched by means of `useWatch()` will have `started = true` at this moment. 109 | 110 | The return value of `run()` is the action's result object. Generally it is unimportant, and `run()` is mostly used for initiating watched actions, or initiating updates. 111 | 112 | `run()` also takes an optional options object: 113 | 114 | ```jsx 115 | const result = await searchPicturesForTag.run({ tag }, options); 116 | ``` 117 | 118 | The structure of the options: 119 | 120 | ```tsx 121 | interface Options { 122 | treatAsUpdate: boolean, // default = false 123 | respectCache: boolean, // default = false 124 | ignoreShortCircuit: boolean, // default = false 125 | } 126 | ``` 127 | 128 | #### `treatAsUpdate` 129 | 130 | As seen in the hooks for `useWatch()` and `useBeckon()`, there is an extra return value called `updating` which will be set to `true` if these conditions are met: 131 | 132 | * The action is `run()` with `treatAsUpdate: true` passed as an option. 133 | 134 | * The action has previously completed 135 | 136 | If these conditions are met, then `finished` shall remain `true`, and the current cached result unchanged, and `updating` will now be `true` as well. This allows the edge case of updating your UI to show that updates to the already loaded data are incoming. 137 | 138 | #### `respectCache` 139 | 140 | By default, when you directly `run()` an action, we ignore the cached values and initiate an entire new action run from the beginning. You can think of a `run()` as if we're running our action like we would a regular promise. 141 | 142 | But there are times when you do actually want to hit the cache on a direct run, specifically when you are making use of a [post-action hook](async-post-action-hook.md) - where you just want your run of the action to trigger the relevant UI updates that are associated with this action's result, for example. 143 | 144 | #### `ignoreShortCircuit` 145 | 146 | If set to `true`, will not run the [short circuit hook](async-short-circuit-hook.md) for this run of the action. 147 | 148 | ## `InjectAsyncAction` component 149 | 150 | You could also inject Async Action state directly into your React app without a hook. 151 | 152 | This is particularly useful for things like watching the state of an image loading. If we take this Async Action as an example: 153 | 154 | ```tsx 155 | async function loadImageFully(src: string) { 156 | return new Promise((resolve, reject) => { 157 | let img = new Image(); 158 | img.onload = resolve; 159 | img.onerror = reject; 160 | img.src = src; 161 | }); 162 | } 163 | 164 | export const AsyncActionImageLoad = createAsyncAction<{ src: string }>(async ({ src }) => { 165 | await loadImageFully(src); 166 | return successResult(); 167 | }); 168 | ``` 169 | 170 | We can inject the async state of loading an image directly into our App using ``: 171 | 172 | ```tsx 173 | 177 | {([finished]) => { 178 | return
186 |
187 | }} 188 |
189 | ``` 190 | 191 | We've very quickly made our App have images which will fade in once completely loaded! (You'd probably want to turn this into a component of its own and simply use the hooks - but as an example its fine for now) 192 | 193 | You can make use of the exported `EAsyncActionInjectType` which provides you with `BECKON` or `WATCH` constant variables - or you can provide them as a strings directly `"beckon"` or `"watch"`. 194 | 195 | ## Clear an Async Action's cache 196 | 197 | ```tsx 198 | searchPicturesForTag.clearCache({ tag }); 199 | ``` 200 | 201 | Clears all known state about this action (specific to the passed arguments). 202 | 203 | * Any action that is still busy resolving will have its results ignored. 204 | 205 | * Any watched actions ( `useWatch()` ) will return to their neutral state (i.e. `started = false`) 206 | 207 | * Any beckoned actions (`useBeckon()`) will have their actions re-instigated anew. 208 | 209 | ## Clear the Async Action cache for *all* argument combinations 210 | 211 | ```tsx 212 | searchPicturesForTag.clearAllCache(); 213 | ``` 214 | 215 | This is the same as `clearCache()`, except it will clear the cache for every single argument combination (the "fingerprints" we spoke of before) that this action has seen. 216 | 217 | ## Clear the Async Action cache for unwatched argument combinations 218 | 219 | ```tsx 220 | searchPicturesForTag.clearAllUnwatchedCache(); 221 | ``` 222 | 223 | This will check which argument combinations are not being "watched' in your React app anymore (i.e. usages of `useWatch()` , `useBeckon()` or ``), and will clear the cache for those argument combinations. Pending actions for these arguments are not cleared. 224 | 225 | This is useful for simple garbage collection in Apps which tend to show lots of ever-changing data - which most likely won't be returned to (perhaps data based on the current time). 226 | 227 | ## Get, Set and Update Async Action cache 228 | 229 | Pullstate provides three extra methods which allow you to introspect and even change the current value stored in the cache. they are as follows: 230 | 231 | ```tsx 232 | searchPicturesForTag.getCached(args, options); 233 | searchPicturesForTag.setCached(args, result, options); 234 | searchPicturesForTag.updateCached(args, updater, options); 235 | ``` 236 | 237 | ### `getCached(args, options)` 238 | 239 | You pass the action arguments for which you expect a cached result from this action as the first parameter, and optionally you can pass the following `options`: 240 | 241 | ```tsx 242 | { 243 | checkCacheBreak: boolean; // default = false 244 | } 245 | ``` 246 | 247 | If `true` is passed here, then our [`cacheBreakHook`](async-cache-break-hook.md) for this action will be checked, and if this cache can be broken at the moment - `cacheBreakable` will be set to `true` in the response. 248 | 249 | The function will return an object which represents the current state of our cache for the passed arguments: 250 | 251 | ```tsx 252 | { 253 | started: boolean; 254 | finished: boolean; 255 | result: { 256 | error: boolean; 257 | payload: any; 258 | message: string; 259 | tags: string[]; 260 | }; 261 | updating: boolean; 262 | existed: boolean; 263 | cacheBreakable: boolean; 264 | timeCached: number; 265 | } 266 | ``` 267 | 268 | If no cached value is found `existed` will be `false`. 269 | 270 | ### `setCached(args, result, options)` 271 | 272 | You pass the arguments you'd like to set the cached value for as the first parameter, and the new cached `result` value as the second parameter: 273 | 274 | ```tsx 275 | { 276 | error: boolean; 277 | payload: any; 278 | message: string; 279 | tags: string[]; 280 | } 281 | ``` 282 | 283 | (Hint: You can use convenience functions [`successResult()`](async-actions-creating.md#convenience-function-for-success) and [`errorResult()`](async-actions-creating.md#convenience-function-for-error) to help with this ) 284 | 285 | A convenience method also exists for the majority of circumstances when you are just setting a success payload: 286 | 287 | ```tsx 288 | setCachedPayload(args, payload, options) 289 | ``` 290 | 291 | You can provide an `options` object to either of these methods: 292 | 293 | ```tsx 294 | { 295 | notify?: boolean; // default = true 296 | } 297 | ``` 298 | 299 | If `notify` is `true` (the default), then any listeners on this Async Action for these arguments will be notified and reflect the changes of the new cached value. 300 | 301 | It has no return value. 302 | 303 | ### `updateCached(args, updater, options)` 304 | 305 | This is similar to `setCached()`, but only runs on an already cached and non-error state cached value. Hence, we only need to affect `payload` on the result object. 306 | 307 | It works exactly the same as a regular store `update()`, except it acts on the currently cached `payload` value for the passed arguments. So, `updater` is a function that looks like this: 308 | 309 | ```tsx 310 | (currentlyCachedPayload) => { 311 | // directly mutate currentlyCachedPayload here 312 | }; 313 | ``` 314 | 315 | Optionally you can provide some options: 316 | 317 | ```tsx 318 | notify?: boolean; // default = true 319 | resetTimeCached?: boolean; // default = true 320 | ``` 321 | 322 | `notify` is the same as in `setCached()`. 323 | 324 | If `resetTimeCached` is `true` Pullstate will internally set a new value for `timeCached` to the current time. 325 | -------------------------------------------------------------------------------- /docs/async-actions-creating.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-actions-creating 3 | title: Creating an Async Action 4 | sidebar_label: Creating an Async Action 5 | --- 6 | 7 | **Note the tabs in these examples. If you are server-rendering, switch to the "Server-rendered app" tab.** 8 | 9 | Create an Async Action like so: 10 | 11 | 12 | 13 | 14 | ```tsx 15 | import { createAsyncAction } from "pullstate"; 16 | 17 | const myAsyncAction = createAsyncAction(action, hooksAndOptions); 18 | ``` 19 | 20 | 21 | 22 | ```tsx 23 | import { PullstateCore } from "./PullstateCore"; 24 | 25 | const myAsyncAction = PullstateCore.createAsyncAction(action, hooksAndOptions); 26 | ``` 27 | 28 | Server-rendered apps need to make use of your "core" Pullstate object to create Async Actions which can pre-fetch on the server. 29 | 30 | > Some of these examples will be making use of **client-side** only code to keep things simple and rather focus on the differences between TypeScript and JavaScript interactions. The server-rendering considerations to convert such code is explained in other examples, in the relevant tabs. 31 | 32 | 33 | 34 | We pass in two arguments. First, our actual `action`, and secondly, any [`hooks`](async-hooks-overview.md) we would like to set on this action to extend its functionality. 35 | 36 | ## The action itself 37 | 38 | The argument we pass in for `action` is pretty much just a standard `async` / `Promise`-returning function, but there are some extra considerations we need to keep in mind. 39 | 40 | To illustrate these considerations, lets use an example Async Action (fetching pictures related to a tag from an API) and its usage: 41 | 42 | 43 | 44 | 45 | ```tsx 46 | import { createAsyncAction, errorResult, successResult } from "pullstate"; 47 | 48 | const searchPicturesForTag = createAsyncAction(async ({ tag }) => { 49 | const result = await PictureApi.searchWithTag(tag); 50 | 51 | if (result.success) { 52 | return successResult(result.pictures); 53 | } 54 | 55 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 56 | }); 57 | 58 | export const PictureExample = props => { 59 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 60 | 61 | if (!finished) { 62 | return
Loading Pictures for tag "{props.tag}"
; 63 | } 64 | 65 | if (result.error) { 66 | return
{result.message}
; 67 | } 68 | 69 | return ; 70 | }; 71 | ``` 72 | 73 | 74 | 75 | ```tsx 76 | import { createAsyncAction, errorResult, successResult } from "pullstate"; 77 | 78 | interface IOSearchPicturesForTagInput { 79 | tag: string; 80 | } 81 | 82 | interface IOSearchPicturesForTagOutput { 83 | pictures: Picture[]; 84 | } 85 | 86 | const searchPicturesForTag = createAsyncAction( 87 | async ({ tag }) => { 88 | const result = await PictureApi.searchWithTag(tag); 89 | 90 | if (result.success) { 91 | return successResult({ pictures: result.pictures }); 92 | } 93 | 94 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 95 | } 96 | ); 97 | 98 | export const PictureExample = (props: { tag: string }) => { 99 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 100 | 101 | if (!finished) { 102 | return
Loading Pictures for tag "{props.tag}"
; 103 | } 104 | 105 | if (result.error) { 106 | return
{result.message}
; 107 | } 108 | 109 | return ; 110 | }; 111 | ``` 112 | 113 | 114 | 115 | ### The cachable "fingerprint" 116 | 117 | The first important concept to understand has to do with caching. For the **same arguments**, we do not want to be running these actions over and over again each time we hit them in our component code - what we really only want is the final result of these actions. So we need to be able to cache the results and re-use them where possible. Don't worry, Pullstate provides easy ways to ["break" this cache](async-cache-clearing.md) where needed as well. 118 | 119 | Pullstate does this by internally creating a "fingerprint" from the arguments which are passed in to the action. In our example here, the fingerprint is created from: 120 | 121 | ```tsx 122 | { tag: props.tag; } 123 | ``` 124 | 125 | So, in the example, if on initial render we pass`{ tag: "dog" }` as props to our component, it will run the action for the first time with that fingerprint. Then, if we pass something new like `{ tag: "tree" }`, the action will run for that tag for the first time too. Both of these results are now cached per their arguments. If we pass `{ tag: "dog" }` again, the action will not run again but instead return our previously cached result. 126 | 127 | **Importantly:** Always have your actions defined with as many arguments which identify that single action as possible! (But no more than that - be as specific as possible while being as brief as possible). 128 | 129 | That said, there very well _could_ be reasons to create async actions that have no arguments and there are [ways you can cache bust](async-cache-clearing.md) actions to cause them to run again with the same "fingerprint". 130 | 131 | ### What to return from an action 132 | 133 | Your action should return a result structured in a certain way. Pullstate provides convenience methods for this, depending on whether you want to return an error or a success - as can be seen in the example where we return `successResult()` or `errorResult()`. 134 | 135 | This result structure is as follows: 136 | 137 | ```tsx 138 | { 139 | error: boolean; 140 | message: string; 141 | tags: string[]; 142 | payload: any; 143 | } 144 | ``` 145 | 146 | ### Convenience function for success 147 | 148 | Will set `{ error: false }` on the result object e.g: 149 | 150 | ```tsx 151 | // successResult(payload = null, tags = [], message = "") <- default arguments 152 | return successResult({ pictures: result.pictures }); 153 | ``` 154 | 155 | ### Convenience function for error 156 | 157 | Will set `{ error: true }` on the result object e.g: 158 | 159 | ```tsx 160 | // errorResult(tags = [], message = "", errorPayload = undefined) <- default arguments 161 | return errorResult(["NO_USER_FOUND"], "No user found in database by that name", errorPayload); 162 | ``` 163 | 164 | The `tags` property here is a way to easily react to more specific error states in your UI. The default error result, when you haven't caught the errors yourself, will return with a single tag: `["UNKNOWN_ERROR"]`. If you return an error with `errorResult()`, the tag `"RETURNED_ERROR"` will automatically be added to tags. You may optionally also pass a `errorPayload` as a third argument if you need to access additional error data from the result. 165 | 166 | ## Update our state stores with async actions 167 | 168 | In our example we didn't actually touch our Pullstate stores, and that's just fine - there are many times where we just need to listen to asynchronous state without updating our stores (waiting for `Image.onload()` for example). 169 | 170 | But the Pullstate Way™ is generally to maintain our state in our stores for better control over things. 171 | 172 | A naive way to do this might be like so: 173 | 174 | **This code, while functionally correct, will cause unexpected behaviour!** 175 | 176 | 177 | 178 | 179 | ```tsx 180 | import { createAsyncAction, errorResult, successResult } from "pullstate"; 181 | import { GalleryStore } from "./stores/GalleryStore"; 182 | 183 | const searchPicturesForTag = createAsyncAction(async ({ tag }) => { 184 | const result = await PictureApi.searchWithTag(tag); 185 | 186 | if (result.success) { 187 | GalleryStore.update(s => { 188 | s.pictures = result.pictures; 189 | }); 190 | return successResult(); 191 | } 192 | 193 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 194 | }); 195 | 196 | export const PictureExample = (props: { tag: string }) => { 197 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 198 | 199 | if (!finished) { 200 | return
Loading Pictures for tag "{props.tag}"
; 201 | } 202 | 203 | if (result.error) { 204 | return
{result.message}
; 205 | } 206 | 207 | // Inside the Gallery component we will pull our state 208 | // from our stores directly instead of passing it as a prop 209 | return ; 210 | }; 211 | ``` 212 | 213 | 214 | 215 | ```tsx 216 | import { PullstateCore } from "./PullstateCore"; 217 | 218 | const searchPicturesForTag = PullstateCore.createAsyncAction( 219 | async ({ tag }, { GalleryStore }) => { 220 | const result = await PictureApi.searchWithTag(tag); 221 | 222 | if (result.success) { 223 | GalleryStore.update(s => { 224 | s.pictures = result.pictures; 225 | }); 226 | return successResult(); 227 | } 228 | 229 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 230 | } 231 | ); 232 | ``` 233 | 234 | Something to notice here quick is that for server-rendered apps, we must make use of the second argument in our defined action which is the collection of stores being used on this render / server request. 235 | 236 | ```tsx 237 | export const PictureExample = (props: { tag: string }) => { 238 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 239 | 240 | if (!finished) { 241 | return
Loading Pictures for tag "{props.tag}"
; 242 | } 243 | 244 | if (result.error) { 245 | return
{result.message}
; 246 | } 247 | 248 | // Inside the Gallery component we will pull our state 249 | // from our stores directly instead of passing it as a prop 250 | return ; 251 | }; 252 | ``` 253 | 254 | 255 | 256 | So what exactly is the problem? At first glance it might not be very clear. 257 | 258 | **The problem:** Because our actions are cached, when we return to a previously run action (with the same "fingerprint" of arguments) the action will not be run again, and our store will not be updated. 259 | 260 | To find out how to work with these scenarios, check out [Async Hooks](async-hooks-overview.md) - and specifically for this scenario, we would make use of the [`postActionHook()`](async-post-action-hook.md). 261 | -------------------------------------------------------------------------------- /docs/async-actions-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-actions-introduction 3 | title: Introduction to Async Actions 4 | sidebar_label: Introduction 5 | --- 6 | 7 | More often than not, our stores do not exist in purely synchronous states. We often need to perform actions asynchronously, such as pulling data from an API. 8 | 9 | * It would be nice to have an easy way to keep our view up to date with the state of these actions **without putting too much onus on our stores directly** which quickly floods them with variables such as `userLoading`, `updatingUserInfo`, `userLoadError` etc - which we then have to make sure we're handling for each unique situation - it just gets messy quickly. 10 | 11 | * Having our views naturally listen for and initiate asynchronous state gets rid of a lot of boilerplate which we would usually need to write in `componentDidMount()` or the `useEffect()` hook. 12 | 13 | * There are also times where we are **server-rendering** and we would like to resolve our app's asynchronous state before rendering to the user. And again, without having to run something manually (and deal with all the edge cases manually too) for example: 14 | 15 | ```jsx 16 | try { 17 | const posts = await PostApi.getPostListForTag(tag); 18 | 19 | instance.stores.PostStore.update(s => { 20 | s.posts = posts; 21 | }); 22 | } catch (e) { 23 | instance.stores.PostStore.update(s => { 24 | s.posts = []; 25 | s.postError = e.message; 26 | }); 27 | } 28 | ``` 29 | 30 | As you can imagine, separating this out and running such code for every case of state that you want pre-fetched before rendering to the client will get very verbose very quickly. 31 | 32 | Pullstate provides a much easier way to handle async scenarios through **Async Actions**! -------------------------------------------------------------------------------- /docs/async-actions-other-options.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-actions-other-options 3 | title: Other Async Action Options 4 | sidebar_label: Other Async Action Options 5 | --- 6 | 7 | There are some other options we can pass in as the second value when creating Async Actions. 8 | 9 | ```tsx 10 | import { createAsyncAction } from "pullstate"; 11 | 12 | const myAsyncAction = createAsyncAction(action, hooksAndOptions); 13 | ``` 14 | 15 | Besides hooks, we can also (optionally) pass in: 16 | 17 | ``` 18 | { 19 | subsetKey: (args: any) => string; 20 | forceContext: boolean; 21 | } 22 | ``` 23 | 24 | ## `subsetKey` 25 | 26 | This is a function you can pass in, which intercepts the creation of this Async Action's "fingerprint" (or _key_). 27 | 28 | Basically, it takes in the current arguments passed to the action and returns a fingerprint to use in the cache. This could potentially give a performance boost when you are passing in really large argument sets. 29 | 30 | ## `forceContext` 31 | 32 | You can pass in `true` here in order to force this action to use Pullstate's context to grab its caching and execution state. This is useful if you have defined your Async Action outside of your current project, and without `PullstateCore` (see ["Creating an Async Action"](async-actions-creating.md) - under "Server-rendered app"). It basically makes an action which might seem "client-only" in its creation, force itself to act like a SSR action, using whatever Pullstate context is available. 33 | -------------------------------------------------------------------------------- /docs/async-cache-break-hook.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-cache-break-hook 3 | title: Cache break hook 4 | sidebar_label: Cache break hook 5 | --- 6 | 7 | The cache break hook has the following API: 8 | 9 | ```tsx 10 | cacheBreakHook({ args, result, stores, timeCached }) => true | false 11 | ``` 12 | 13 | > As per all Async Action things, `stores` here is only available as an option if you are making use of `` in your app (server-side rendering). 14 | 15 | It should return `true` or `false`. 16 | 17 | This action will only run if a cached result is found for this action (i.e. this action has completed already in the past). If you return `true`, this will "break" the currently cached value for this action. This action will now run again. 18 | 19 | Be sure to check out the [async hooks flow diagram](async-hooks-overview.md#async-hooks-flow-diagram) to understand better where this hook fits in. 20 | 21 | ## Example of a cache break hook 22 | 23 | _Deciding to not used the cached result from a search API when the search results are more than 30 seconds old_ 24 | 25 | ```tsx 26 | const THIRTY_SECONDS = 30 * 1000; 27 | 28 | // The cache break hook in your action creator 29 | 30 | cacheBreakHook: ({ result, timeCached }) => 31 | !result.error && timeCached + THIRTY_SECONDS < Date.now(), 32 | ``` 33 | 34 | In this example want to break the cached result if it is not an error, and the `timeCached` is older than 30 seconds from `Date.now()`. `timeCached` is passed in, and is the millisecond epoch time of when our action last completed. 35 | 36 | You can create customized caching techniques as you see fit. Here we simply check against `timeCached`. Potentially, you might want to check other variables set in your stores, something set on your response payload or even use one of the passed arguments to affect caching length. 37 | 38 | Be sure to check out [the section on cache clearing](async-cache-clearing.md) for other ways to deal with cache invalidation. 39 | -------------------------------------------------------------------------------- /docs/async-cache-clearing.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-cache-clearing 3 | title: Async cache clearing 4 | sidebar_label: Cache clearing 5 | --- 6 | 7 | A big part of working with asynchronous data is being able to control the cache. 8 | 9 | There's even a famous quote for it: 10 | 11 | > _"There are only two hard things in Computer Science: cache invalidation and naming things."_ 12 | > 13 | > -**Phil Karlton** 14 | 15 | To try and make at least one of those things a bit easier for you, Pullstate provides a few different ways of dealing with cache invalidation with your async actions. 16 | 17 | ## Direct cache invalidation 18 | 19 | There are three "direct" ways to invalidate the cache for an action: 20 | 21 | ### Clear the cache for specific arguments (fingerprint) 22 | 23 | [See more](async-action-use.md#clear-an-async-action-s-cache) 24 | 25 | ```tsx 26 | GetUserAction.clearCache({ userId }); 27 | ``` 28 | 29 | ### Clear the cache completely for an action (all combinations of arguments) 30 | 31 | [See more](async-action-use.md#clear-the-async-action-cache-for-all-argument-combinations) 32 | 33 | ```tsx 34 | GetUserAction.clearAllCache(); 35 | ``` 36 | 37 | ### Clear all unwatched cache for an action 38 | 39 | [See more](async-action-use.md#clear-the-async-action-cache-for-unwatched-argument-combinations) 40 | 41 | ```tsx 42 | GetUserAction.clearAllUnwatchedCache(); 43 | ``` 44 | 45 | ## Conditional cache invalidation 46 | 47 | There is also a way to check and clear the cache automatically, using something called a `cacheBreakHook` - which runs when an action is called which already has a cached result, and decides whether the current cached result is still worthy. Check out the [async hooks flow diagram](async-hooks-overview.md#async-hooks-flow-diagram) to better understand how this hook fits in. 48 | 49 | ### [Cache Break Hook](async-cache-break-hook.md) 50 | 51 | ```tsx 52 | cacheBreakHook: ({ result, args, timeCached }) => true | false 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/async-hooks-overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-hooks-overview 3 | title: Async hooks overview 4 | sidebar_label: Hooks overview 5 | --- 6 | 7 | The second argument while creating Async Actions allows us to pass hooks for the action: 8 | 9 | ```tsx 10 | const searchPicturesForTag = createAsyncAction(async ({ tag }) => { 11 | // action code 12 | }, hooksGoHere); 13 | ``` 14 | 15 | This `hooksGoHere` object has three hook types which we can set for this action, as follows: 16 | 17 | ```tsx 18 | { 19 | postActionHook, 20 | shortCircuitHook, 21 | cacheBreakHook 22 | } 23 | ``` 24 | 25 | ## Async hooks flow diagram 26 | 27 | To try and give you a quick overview of how actions work with hooks, lets look at a top-down flow diagram of an Async Action's execution: 28 | 29 | ![Async Action hooks in action](assets/async-flow.png) 30 | 31 | ## Quick overview of each 32 | 33 | ### `postActionHook({ args, result, stores, context })` 34 | 35 | Post action hook is for consistently running state updates after an action completes for the first time or hits a cached value. 36 | 37 | Read more on the [post action hook](async-post-action-hook.md). 38 | 39 | ### `shortCircuitHook({ args, stores })` 40 | 41 | The short circuit hook is for checking the current state of your app and manually deciding that an action does not actually need to be run, and returning a replacement resolved value yourself. 42 | 43 | Read more on the [short circuit hook](async-short-circuit-hook.md). 44 | 45 | ### `cacheBreakHook({ args, result, stores, timeCached })` 46 | 47 | This hook is run only when an action has already resolve at least once. It takes the currently cached value and decides whether we should "break" the cache and run the action again instead of returning it. 48 | 49 | Read more on the [cache break hook](async-cache-break-hook.md). 50 | 51 | --- 52 | 53 | **NOTE:** In all these hooks, `stores` is only available when you are doing server rendering and you have used your centralized Pullstate "Core" to create your Async Actions, and are making use of `` (see the server-rendering part [Creating an Async Action](async-actions-creating.md)). If you have a client-side only app, just import and use your stores directly. 54 | -------------------------------------------------------------------------------- /docs/async-post-action-hook.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-post-action-hook 3 | title: Post action hook 4 | sidebar_label: Post action hook 5 | --- 6 | 7 | `postActionHook()` is a function which is run directly after your action has completed. **And most importantly**, this is also run after we hit an already resolved and cached value. 8 | 9 | This is useful for updating our app's state (mostly concerning views, organising action results into specific store state that's in the current app's focus) in a consistent manner after actions, whether we hit the cache or directly ran them for the first time. 10 | 11 | Be sure to check out the [async hooks flow diagram](async-hooks-overview.md#async-hooks-flow-diagram) to understand better where this hook fits in. 12 | 13 | ### Updating our regular state stores with Async Action data using `postActionHook` 14 | 15 | `postActionHook` is most often used to synchronise the asynchronous state returned after our Async Actions into our stores. 16 | 17 | Let's quickly look at our previously explored **naive** example from [Creating an Async Action](async-actions-creating.md): 18 | 19 | > If you are not server-rendering you can ignore `PullstateCore` here and create your actions directly with `createAsyncAction()` - you would then also be importing and using your stores directly as opposed to the stores parameter passed inside the action function 20 | 21 | **DO NOT DO THIS!** 22 | 23 | ```tsx 24 | const searchPicturesForTag = PullstateCore.createAsyncAction(async ({ tag }, stores) => { 25 | const result = await PictureApi.searchWithTag(tag); 26 | 27 | if (result.success) { 28 | stores.GalleryStore.update(s => { 29 | s.pictures = result.pictures; 30 | }); 31 | return successResult(); 32 | } 33 | 34 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 35 | }); 36 | ``` 37 | 38 | Here we are updating our `GalleryStore` inside the action. The problem with this is that upon hitting a cached value, this action will not be run again ([unless cache broken](async-cache-clearing.md)) - and hence the `pictures` state inside the store will not be replaced with the new pictures. 39 | 40 | In comes `postActionHook` to save the day: 41 | 42 | ```tsx 43 | const searchPicturesForTag = PullstateCore.createAsyncAction( 44 | async ({ tag }) => { 45 | const result = await PictureApi.searchWithTag(tag); 46 | 47 | if (result.success) { 48 | return successResult(result); 49 | } 50 | 51 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 52 | }, 53 | { 54 | postActionHook: ({ result, stores }) => { 55 | if (!result.error) { 56 | stores.GalleryStore.update(s => { 57 | s.pictures = result.payload.pictures; 58 | }); 59 | } 60 | }, 61 | } 62 | ); 63 | ``` 64 | 65 | > _`stores` here is a server-rendering only argument. For client-side only rendering, import and update your stores directly_ 66 | 67 | Notice how we removed the update logic from the action itself and moved it inside the post action hook. Now our state is guaranteed to be updated the same, no matter if we hit the cache or ran the action directly. 68 | 69 | ## API of `postActionHook`: 70 | 71 | ```tsx 72 | postActionHook(inputs) { // Do things with inputs for this async action run }; 73 | ``` 74 | 75 | `inputs` is the only argument passed to `postActionHook` and has a structure like so: 76 | 77 | ```tsx 78 | { 79 | args, result, stores, context; 80 | } 81 | ``` 82 | 83 | > As per all Async Action things, `stores` here is only available as an option if you are making use of `` in your app (server-side rendering). 84 | 85 | - `args` are the arguments for this run of the Async Action 86 | - `result` is the result of the run 87 | - `stores` is an object with all your state stores (**server rendering only**) 88 | - `context` is the context of where this post action hook was run: 89 | 90 | `context` helps us know how we came to be running this post action hook, and allows us to block certain scenarios (and prevent duplicate runs in some scenarios). It all depends on your app and how you make use of async actions. 91 | 92 | `context` will be set to one of the following values: 93 | 94 | - `BECKON_HIT_CACHE` 95 | - Ran after a [`beckon`](async-action-use.md#beckon-an-async-action-react-hook) hit a cached value on this action (triggered after a UI change where old arguments put into `beckon()` again) 96 | - `WATCH_HIT_CACHE` 97 | - Ran after a [`watch`](async-action-use.md#watch-an-async-action-react-hook) hit a cached value on this action (triggered after a UI change where old arguments put into `watch()` again) 98 | - `RUN_HIT_CACHE` 99 | - Ran after we called [`run`](async-action-use.md#run-an-async-action-directly) on this action with `respectCache: true`, and the cache was hit on this action 100 | - `DIRECT_RUN` 101 | - Ran after we called [`run`](async-action-use.md#run-an-async-action-directly) on this action with `respectCache` not set to `true` 102 | - `SHORT_CIRCUIT` 103 | - Ran after the `shortCircuit` hook finished the action pre-maturely 104 | - `BECKON_RUN` 105 | - Ran after a [`beckon`](async-action-use.md#beckon-an-async-action-react-hook) instigated this action for the first time (or after cache break) 106 | -------------------------------------------------------------------------------- /docs/async-server-rendering.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-server-rendering 3 | title: Resolving async state while server-rendering 4 | sidebar_label: Resolve async state on the server 5 | --- 6 | 7 | ## Universal fetching 8 | 9 | Any action that is making use of `useBeckon()` ([discussed in detail here](async-action-use.md)) in the current render tree can have its state resolved on the server before rendering to the client. This allows us to generate dynamic pages on the fly! 10 | 11 | As per our example in ["Creating an Async Action"](async-actions-creating.md) 12 | 13 | ```tsx 14 | // Create the action 15 | const searchPicturesForTag = PullstateCore.createAsyncAction(async ({ tag }) => { 16 | const result = await PictureApi.searchWithTag(tag); 17 | 18 | if (result.success) { 19 | return successResult(result.pictures); 20 | } 21 | 22 | return errorResult([], `Couldn't get pictures: ${result.errorMessage}`); 23 | }); 24 | 25 | // Use the action state in our components 26 | export const PictureExample = (props: { tag: string }) => { 27 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 28 | 29 | if (!finished) { 30 | return
Loading Pictures for tag "{props.tag}"
; 31 | } 32 | 33 | if (result.error) { 34 | return
{result.message}
; 35 | } 36 | 37 | // Inside the Gallery component we will pull our state 38 | // from our stores directly instead of passing it as a prop 39 | return ; 40 | }; 41 | ``` 42 | 43 | So looking directly at `beckon()`: 44 | 45 | ```tsx 46 | const [finished, result] = searchPicturesForTag.useBeckon({ tag: props.tag }); 47 | ``` 48 | 49 | Using server-side async resolving, when our app hydrates for the first time on the client - `finished` will be `true` and `result` will already be resolved. 50 | 51 | **But very importantly:** The asynchronous action code needs to be able to resolve on both the server and client - so make sure that your data-fetching functions are "isomorphic" or "universal" in nature. Examples of such functionality are the [Apollo Client](https://www.apollographql.com/docs/react/api/apollo-client.html) or [Wildcard API](https://github.com/brillout/wildcard-api). 52 | 53 | In our example here, this API code would have to be "universally" resolvable: 54 | 55 | ```tsx 56 | const result = await PictureApi.searchWithTag(tag); 57 | ``` 58 | 59 | ## Excluding async state from resolving on the server 60 | 61 | If you wish to have the regular behaviour of `useBeckon()` (that it instigates the async action when hit) but you don't actually want the server to resolve this asynchronous state (you're happy for it to load on the client-side only). You can pass in an option to `useBeckon()`: 62 | 63 | ```tsx 64 | const [finished, result, updating] = GetUserAction.useBeckon({ userId }, { ssr: false }); 65 | ``` 66 | 67 | Passing in `ssr: false` will cause this action to be ignored in the server asynchronous state resolve cycle. 68 | 69 | ## Resolving async state on the server 70 | 71 | Pullstate provides two ways to resolve your async state on the server - re-rendering until all state is resolved, and resolving state by running the actions directly off your initiated Pullstate "Core" before rendering. 72 | 73 | ### Re-render until resolved 74 | 75 | Until there is a better way to crawl through your react tree, the current easiest way to resolve async state on the server-side while rendering your React app is to simply render it multiple times. This allows Pullstate to register which async actions are required to resolve before we do our final render for the client. 76 | 77 | Using the `instance` which we create from our `PullstateCore` object (see [Server Rendering Example](quick-example-server-rendered.md#gather-stores-under-a-core-collection)) of all our stores: 78 | 79 | ```tsx 80 | const instance = PullstateCore.instantiate({ ssr: true }); 81 | 82 | // (1) 83 | const app = ( 84 | 85 | 86 | 87 | ) 88 | 89 | let reactHtml = ReactDOMServer.renderToString(app); 90 | 91 | // (2) 92 | while (instance.hasAsyncStateToResolve()) { 93 | await instance.resolveAsyncState(); 94 | reactHtml = ReactDOMServer.renderToString(app); 95 | } 96 | 97 | // (3) 98 | const snapshot = instance.getPullstateSnapshot(); 99 | 100 | const body = ` 101 | 102 | ${reactHtml}`; 103 | ``` 104 | 105 | As marked with numbers in the code: 106 | 107 | 1. Place your app into a variable for ease of use. After which, we do our initial rendering as usual - this will register the initial async actions which need to be resolved onto our Pullstate `instance`. 108 | 109 | 2. We enter into a `while()` loop using `instance.hasAsyncStateToResolve()`, which will return `true` unless there is no async state in our React tree to resolve. Inside this loop we immediately resolve all async state with `instance.resolveAsyncState()` before rendering again. This renders our React tree until all state is deeply resolved. 110 | 111 | 3. Once there is no more async state to resolve, we can pull out the snapshot of our Pullstate instance - and we stuff that into our HTML to be hydrated on the client. 112 | 113 | ### Resolve Async Actions outside of render 114 | 115 | If you really wish to avoid the re-rendering, Async Actions are runnable on your Pullstate `instance` directly as well. This will "pre-cache" these action responses and hence not require a re-render (`instance.hasAsyncStateToResolve()` will return false). 116 | 117 | We make use of the following API on our Pullstate instance: 118 | 119 | ```tsx 120 | await pullstateInstance.runAsyncAction(CreatedAsyncAction, args, options); 121 | ``` 122 | 123 | The `options` parameter here is the same as that defined on the regular [`run()` method on an action](async-action-use.md#run-an-async-action-directly). The key options being, `ignoreShortCircuit` (default `false`) and `respectCache` (default `false`). 124 | 125 | If you are running actions on the client side again before rendering your app for the first time (perhaps using some kind of isomorphic routing library) - you should be passing the option `{ respectCache: true }` on the client so these actions do not run again. 126 | 127 | This example makes use of `koa` and `koa-router`, we inject our instance onto our request's `ctx.state` early on in the request so we can use it along the way until finally rendering our app. 128 | 129 | Put the Pullstate instance into the current context: 130 | 131 | ```tsx 132 | ServerReactRouter.get("/*", async (ctx, next) => { 133 | ctx.state.pullstateInstance = PullstateCore.instantiate({ ssr: true }); 134 | await next(); 135 | }); 136 | ``` 137 | 138 | Create the routes, which run the various required actions: 139 | 140 | ```tsx 141 | ServerReactRouter.get("/list-cron-jobs", async (ctx, next) => { 142 | await ctx.state.pullstateInstance.runAsyncAction(CronJobAsyncActions.getCronJobs, { limit: 30 }); 143 | await next(); 144 | }); 145 | 146 | ServerReactRouter.get("/cron-job-detail/:cronJobId", async (ctx, next) => { 147 | const { cronJobId } = ctx.params; 148 | await ctx.state.pullstateInstance.runAsyncAction(CronJobAsyncActions.loadCronJob, { id: cronJobId }); 149 | await next(); 150 | }); 151 | 152 | ServerReactRouter.get("/edit-cron-job/:cronJobId", async (ctx, next) => { 153 | const { cronJobId } = ctx.params; 154 | await ctx.state.pullstateInstance.runAsyncAction(CronJobAsyncActions.loadCronJob, { id: cronJobId }); 155 | await next(); 156 | }); 157 | ``` 158 | 159 | And render the app: 160 | 161 | ```tsx 162 | ServerReactRouter.get("*", async (ctx) => { 163 | const { pullstateInstance } = ctx.state; 164 | 165 | // render React app with pullstate instance 166 | 167 | 168 | 169 | ``` 170 | 171 | > Even though you are resolving actions outside of the render cycle, **you still need to use** `` as its the only way to provide your pre-run action's state to your app during rendering. If you didn't put that instance into the provider, `useBeckon()` can't see the result and will have queued up another run the regular way (multiple renders required). 172 | 173 | The Async Actions you use on the server and the ones you use on the client are exactly the same - so they are really nice for server-rendered SPAs. Everything just runs and caches as needed. 174 | 175 | You could even pre-cache a few pages on the server at once if you like (depending on how big you want the initial page payload to be), and have instant page changes on the client (and Async Actions has custom [cache busting built in](async-cache-clearing.md) to invalidate async state which is too stale - such as the user taking too long to change the page). -------------------------------------------------------------------------------- /docs/async-short-circuit-hook.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: async-short-circuit-hook 3 | title: Short circuit hook 4 | sidebar_label: Short circuit hook 5 | --- 6 | 7 | The short circuit hook has the following API: 8 | 9 | ```tsx 10 | shortCircuitHook({ args, stores }) => false | asyncActionResult 11 | ``` 12 | 13 | > As per all Async Action things, `stores` here is only available as an option if you are making use of `` in your app (server-side rendering). 14 | 15 | It should either return `false` or an Async Action result. 16 | 17 | If you return an Async Action result, this will effectively "short-circuit" this action. The Promise for this action will not run, and the action will continue from the point directly after that: caching this result, running the [post-action hook](async-post-action-hook.md) and finishing. 18 | 19 | Be sure to check out the [async hooks flow diagram](async-hooks-overview.md#async-hooks-flow-diagram) to understand better where this hook fits in. 20 | 21 | ## Example of short circuit 22 | 23 | _Deciding not to run a search API when the current search term is less than 1 character - return an empty list straight away_ 24 | 25 | ```tsx 26 | shortCircuitHook: ({ args }) => { 27 | if (args.text.length <= 1) { 28 | return successResult({ posts: [] }); 29 | } 30 | 31 | return false; 32 | }, 33 | ``` 34 | 35 | In this example, if we have a term that's zero or one character's in length - we short-circuit a `successResult()`, an empty list, instead of wasting our connection on a likely useless query. If the text is 2 or more characters, we continue with the action. -------------------------------------------------------------------------------- /docs/inject-store-state.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: inject-store-state 3 | title: 4 | sidebar_label: 5 | --- 6 | 7 | The entirety of the code for `` is as follows: 8 | 9 | 10 | 11 | ```jsx 12 | function InjectStoreState({ store, on, children }) { 13 | const state = useStoreState(store, on); 14 | return children(state); 15 | } 16 | ``` 17 | 18 | 19 |   20 | 21 | `S` = Store State (entire store's state from which you select) 22 | 23 | `SS` = Sub State (which you are selecting to be returned in the child function): 24 | 25 | ```tsx 26 | interface IPropsInjectStoreState { 27 | store: Store; 28 | on?: (state: S) => SS; 29 | children: (output: SS) => React.ReactElement; 30 | } 31 | 32 | function InjectStoreState({ 33 | store, 34 | on = s => s as any, 35 | children, 36 | }: IPropsInjectStoreState): React.ReactElement { 37 | const state: SS = useStoreState(store, on); 38 | return children(state); 39 | } 40 | ``` 41 | 42 | 43 | 44 | ## Props 45 | 46 | As you can see from that, the component `` takes 3 props: 47 | 48 | * `store` - the store from which we are selecting the sub-state 49 | * `on` (optional) - a function which selects the sub-state you want from the store's state 50 | * If non provided, selects the entire store's state (not recommended generally, smaller selections result in less re-rendering) 51 | * The required `children` you pass inside the component is simply a function 52 | * The function executes with a single argument, the sub-state which you have selected 53 | 54 | ## Example 55 | 56 | ```tsx 57 | const GreetUser = () => { 58 | return ( 59 |
60 | s.userName}> 61 | {userName => Hi, {userName}!} 62 | 63 |
64 | ) 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: installation 3 | title: Installation 4 | sidebar_label: Installation 5 | --- 6 | 7 | Let's get the installation out of the way: 8 | 9 | ```powershell 10 | yarn add pullstate 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/quick-example-server-rendered.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quick-example-server-rendered 3 | title: Quick example (server rendering) 4 | sidebar_label: Quick example (server rendering) 5 | --- 6 | 9 | 10 | ## Create a state store 11 | 12 | Let's dive right in and define and export our first **state store**, by passing an initial state to `new Store()`: 13 | 14 | 15 | 16 | ```jsx 17 | import { Store } from "pullstate"; 18 | 19 | export const UIStore = new Store({ 20 | isDarkMode: true, 21 | }); 22 | ``` 23 | 24 | 25 | ```tsx 26 | import { Store } from "pullstate"; 27 | 28 | interface IUIStore { 29 | isDarkMode: boolean; 30 | } 31 | 32 | export const UIStore = new Store({ 33 | isDarkMode: true, 34 | }); 35 | ``` 36 | 37 | 38 | 39 | ## Gather stores under a core collection 40 | 41 | Server-rendering requires that we create a central place to reference all our stores, and we do this using `createPullstateCore()`: 42 | 43 | ```tsx 44 | import { UIStore } from "./stores/UIStore"; 45 | import { createPullstateCore } from "pullstate"; 46 | 47 | export const PullstateCore = createPullstateCore({ 48 | UIStore 49 | }); 50 | ``` 51 | 52 | In this example we only have a single store, but a regular app should have at least a few. 53 | 54 | ## Read our store's state 55 | 56 | Then, in React, we can start using the state of that store using a simple hook `useState()` on the store. 57 | 58 | For server-rendering we also need to make use of `useStores()` on`PullstateCore`, which we defined above. 59 | 60 | > If we were creating a client-only app, we would simply import `UIStore` directly and use it, but for server-rendering we need to get `UIStore` by calling `useStores()`, which uses React's context to get our unique stores for this render / server request 61 | 62 | ```tsx 63 | import * as React from "react"; 64 | import { PullstateCore } from "./PullstateCore"; 65 | 66 | export const App = () => { 67 | const { UIStore } = PullstateCore.useStores(); 68 | const isDarkMode = UIStore.useState(s => s.isDarkMode); 69 | 70 | return ( 71 |
76 |

Hello Pullstate

77 |
78 | ); 79 | }; 80 | ``` 81 | 82 | The second argument to `useState()` over here (`s => s.isDarkMode`), is a selection function that ensures we select only the state that we actually need for this component. This is a big performance booster, as we only listen for changes (and if changed, re-render the component) on the exact returned values - in this case, simply the value of `isDarkMode`. 83 | 84 | If you are not using TypeScript, or want to forgo nice types, you could also pull in your store's using `useStores()` imported directly from `pullstate`: 85 | 86 | ```tsx 87 | import { useStores } from "pullstate"; 88 | 89 | // in app component 90 | const { UIStore } = useStores(); 91 | const isDarkMode = UIStore.useState(s => s.isDarkMode); 92 | ``` 93 | 94 | --- 95 | 96 | ## Add interaction (update state) 97 | 98 | Great, so we are able to pull our state from `UIStore` into our App. Now lets add some basic interaction with a ` 119 | 120 | ); 121 | ``` 122 | 123 | Notice how we call `update()` on `UIStore`, inside which we directly mutate the store's state. This is all thanks to the power of `immer`, which you can check out [here](https://github.com/immerjs/immer). 124 | 125 | Another pattern, which helps to illustrate this further, would be to actually define the action of toggling dark mode to a function on its own: 126 | 127 | 128 | 129 | ```tsx 130 | function toggleMode(s) { 131 | s.isDarkMode = !s.isDarkMode; 132 | } 133 | 134 | // ...in our 136 | ``` 137 | 138 | 139 | ```tsx 140 | function toggleMode(s: IUIStore) { 141 | s.isDarkMode = !s.isDarkMode; 142 | } 143 | 144 | // ...in our 146 | ``` 147 | 148 | 149 | 150 | Basically, to update our app's state all we need to do is create a function (inline arrow function or regular) which takes the current store's state and mutates it to whatever we'd like the next state to be. 151 | 152 | ## Server-rendering our app 153 | 154 | When server rendering we need to wrap our app with `` which is a context provider that passes down fresh stores to be used on each new client request. We get these fresh stores from our `PullstateCore` above, by calling `instantiate({ ssr: true })` on it: 155 | 156 | ```tsx 157 | import { PullstateCore } from "./state/PullstateCore"; 158 | import ReactDOMServer from "react-dom/server"; 159 | import { PullstateProvider } from "pullstate"; 160 | 161 | // A server request 162 | async function someRequest(req) { 163 | const instance = PullstateCore.instantiate({ ssr: true }); 164 | 165 | const preferences = await UserApi.getUserPreferences(id); 166 | 167 | instance.stores.UIStore.update(s => { 168 | s.isDarkMode = preferences.isDarkMode; 169 | }); 170 | 171 | const reactHtml = ReactDOMServer.renderToString( 172 | 173 | 174 | 175 | ); 176 | 177 | const body = ` 178 | 179 | ${reactHtml}`; 180 | 181 | // do something with the generated html and send response 182 | } 183 | ``` 184 | 185 | * Manipulate your state directly during your server's request by using the `stores` property of the instantiated object 186 | 187 | * Notice that we pass our Pullstate core instance into `` as `instance` 188 | 189 | * Lastly, we need to return this state to the client somehow. We call `getPullstateSnapshot()` on the instance, stringify it, escape a couple characters, and set it on `window.__PULLSTATE__`, to be parsed and hydrated on the client. 190 | 191 | ### Quick note 192 | 193 | This kind of code (pulling asynchronous state into your stores on the server and client): 194 | 195 | ```tsx 196 | const preferences = await UserApi.getUserPreferences(id); 197 | 198 | instance.stores.UIStore.update(s => { 199 | s.isDarkMode = preferences.isDarkMode; 200 | }); 201 | ``` 202 | 203 | Can be conceptually made much easier using Pullstate's [Async Actions](async-actions-introduction.md)! 204 | 205 | ## Client-side state hydration 206 | 207 | ```tsx 208 | const hydrateSnapshot = JSON.parse(window.__PULLSTATE__); 209 | 210 | const instance = PullstateCore.instantiate({ ssr: false, hydrateSnapshot }); 211 | 212 | ReactDOM.render( 213 | 214 | 215 | , 216 | document.getElementById("react-mount") 217 | ); 218 | ``` 219 | 220 | We create a new instance on the client using the same method as on the server, except this time we can pass the `hydrateSnapshot` and `ssr: false`, which will instantiate our new stores with the state where our server left off. 221 | 222 | ## Client-side only updates 223 | 224 | Something interesting to notice at this point, which can also apply with server-rendered apps, is that (for client-side only updates) we could just import `UIStore` directly and run `update()` on it: 225 | 226 | ```tsx 227 | import { UIStore } from "./UIStore"; 228 | 229 | // ...in our 231 | ``` 232 | And our components would be updated accordingly. We have freed our app's state from the confines of the component! This is one of the main advantages of Pullstate - allowing us to separate our state concerns from being locked in at the component level and manage things easily at a more global level from which our components listen and react (through our `useStoreState()` hooks). 233 | 234 | We still need to make use of the `PullstateCore.useStores()` hook and `` in order to pick up and render server-side updates and state, but once we have hydrated that state into our stores on the client side, we can interact with Pullstate stores just as we would if it were a client-only app - **but we must be sure that these actions are 100% client-side only**. 235 | -------------------------------------------------------------------------------- /docs/quick-example.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quick-example 3 | title: Quick example 4 | sidebar_label: Quick example 5 | --- 6 | 9 | 10 | ## Create a state store 11 | 12 | Let's dive right in and define and export our first **state store**, by passing an initial state to `new Store()`: 13 | 14 | 15 | 16 | ```jsx 17 | import { Store } from "pullstate"; 18 | 19 | export const UIStore = new Store({ 20 | isDarkMode: true, 21 | }); 22 | ``` 23 | 24 | 25 | ```tsx 26 | import { Store } from "pullstate"; 27 | 28 | interface IUIStore { 29 | isDarkMode: boolean; 30 | } 31 | 32 | export const UIStore = new Store({ 33 | isDarkMode: true, 34 | }); 35 | ``` 36 | 37 | 38 | 39 | ## Read our store's state 40 | 41 | Then, in React, we can start using the state of that store using a simple hook `useState()` on the store iteself: 42 | 43 | ```tsx 44 | import * as React from "react"; 45 | import { UIStore } from "./UIStore"; 46 | 47 | export const App = () => { 48 | const isDarkMode = UIStore.useState(s => s.isDarkMode); 49 | 50 | return ( 51 |
56 |

Hello Pullstate

57 |
58 | ); 59 | }; 60 | ``` 61 | 62 | The argument to `useState()` over here (`s => s.isDarkMode`), is a selection function that ensures we select only the state that we actually need for this component. This is a big performance booster, as we only listen for changes (and if changed, re-render the component) on the exact returned values - in this case, simply the value of `isDarkMode`. 63 | 64 | --- 65 | 66 | ## Add interaction (update state) 67 | 68 | Great, so we are able to pull our state from `UIStore` into our App. Now lets add some basic interaction with a ` 86 | 87 | ); 88 | ``` 89 | 90 | Notice how we call `update()` on `UIStore`, inside which we directly mutate the store's state. This is all thanks to the power of `immer`, which you can check out [here](https://github.com/immerjs/immer). 91 | 92 | Another pattern, which helps to illustrate this further, would be to actually define the action of toggling dark mode to a function on its own: 93 | 94 | 95 | 96 | ```tsx 97 | function toggleMode(s) { 98 | s.isDarkMode = !s.isDarkMode; 99 | } 100 | 101 | // ...in our 103 | ``` 104 | 105 | 106 | ```tsx 107 | function toggleMode(s: IUIStore) { 108 | s.isDarkMode = !s.isDarkMode; 109 | } 110 | 111 | // ...in our 113 | ``` 114 | 115 | 116 | 117 | Basically, to update our app's state all we need to do is create a function (inline arrow function or regular) which takes the current store's state and mutates it to whatever we'd like the next state to be. 118 | 119 | ## Omnipresent state updating 120 | 121 | Something interesting to notice at this point is that we are just importing `UIStore` directly and running `update()` on it: 122 | 123 | ```tsx 124 | import { UIStore } from "./UIStore"; 125 | 126 | // ...in our 128 | ``` 129 | 130 | And our components are being updated accordingly. We have freed our app's state from the confines of the component! This is one of the main advantages of Pullstate - allowing us to separate our state concerns from being locked in at the component level and manage things easily at a more global level from which our components listen and react (through our `useState()` hooks). 131 | -------------------------------------------------------------------------------- /docs/reactions.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: reactions 3 | title: Reactions 4 | sidebar_label: Reactions 5 | --- 6 | 7 | A reaction is very similar to running an `update()`, except that it's an update that runs when a certain value has changed in your store. 8 | 9 | Their API is very similar to [Subscriptions](subscribe.md) in the way they watch values, the difference being that Reactions allow you to react and change your store's state at the same time in a "batched" way. Subscriptions only send you the new values. The reason these two are separated is for performance reasons - if you do not need to react and change your store's state on an update, rather use [subscriptions](subscribe.md). 10 | 11 | Reactions on a store are run directly after a call to `update()` on that store. They check their watched value in the store, and if changed, react with further state updates as you define. This is all batched in one go before notifying our React components to re-render as needed. 12 | 13 | ## Creating a reaction 14 | 15 | You register a reaction by calling `createReaction()` on your store: 16 | 17 | 18 | 19 | ```jsx 20 | StoreName.createReaction(watch, reaction); 21 | ``` 22 | 23 | 24 | ```tsx 25 | type TReactionFunction = (watched: T, draft: S, original: S, lastWatched: T) => void; 26 | 27 | StoreName.createReaction(watch: (state: S) => T, reaction: TReactionFunction): () => void 28 | ``` 29 | 30 | 31 | 32 | Similar to how we select sub-state with `useStoreState()` and ``, the first argument, `watch`, is function which returns a sub-selection of the store's state. This is the value we will be watching for changes: 33 | 34 | ``` 35 | storeState => storeState.watchedValue; 36 | ``` 37 | 38 | The watched value is checked every time the store is updated. If the value has changed, the `reaction` function is run. This is done in a performant way - reactions are run directly after updates before notifying your React components to update. 39 | 40 | The `reaction` function: 41 | 42 | ```jsx 43 | (watched, draft, original, lastWatched) => { //do things }; 44 | ``` 45 | 46 | The new watched value will be passed as the first argument,`watched` 47 | 48 | The next two arguments are the same as those used whe running `update()` on a store: 49 | 50 | * You can mutate your store directly, using `draft` 51 | 52 | * `original` is passed as a performance consideration. It is exactly the same as `draft` but without all the `immer` magic. It's a plain object of your state. 53 | * **Why?** Referencing values directly on your `draft` object can be a performance hit in certain situations because of the way that immer works internally (JavaScript proxies) - so if you need to _reference_ the current store state, you should use `original`. But if you want to _change_ it, you use `draft`. [Read more in immer's docs](https://immerjs.github.io/immer/performance#for-expensive-search-operations-read-from-the-original-state-not-the-draft). 54 | 55 | The last argument is the last watched value, passed as a convenience for if you ever need to refer to it. 56 | 57 | ## Example 58 | 59 | Listening to a [Cron Tab](https://en.wikipedia.org/wiki/Cron#Overview) string value, `crontab`, and calculating other values such as a human readable time and the previous and next dates of the cron run: 60 | 61 | ```tsx 62 | CronJobStore.createReaction(s => s.crontab, (crontab, draft) => { 63 | if (crontab !== null) { 64 | const resp = utils.getTimesAndTextFromCronTab({ crontab }); 65 | if (resp.positive) { 66 | draft.currentCronJobTimesAndText = resp.payload; 67 | } else { 68 | draft.currentCronJobTimesAndText = { 69 | text: `Bad crontab`, 70 | times: { 71 | prevTime: null, 72 | nextTime: null, 73 | }, 74 | }; 75 | } 76 | } else { 77 | draft.currentCronJobTimesAndText = { 78 | times: { 79 | prevTime: null, 80 | nextTime: null, 81 | }, 82 | text: `No crontab`, 83 | }; 84 | } 85 | } 86 | ); 87 | ``` 88 | 89 | ## Unsubscribe from a reaction (client-side only) 90 | 91 | You may unsubscribe from a reaction on the client side of your app by simply running the function which is returned when you created the reaction. 92 | -------------------------------------------------------------------------------- /docs/redux-dev-tools.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: redux-dev-tools 3 | title: Redux Devtools 4 | sidebar_label: Redux Devtools 5 | --- 6 | 7 | Pullstate includes a simple way to plug into Redux's devtools, which are already well established and extensive. 8 | 9 | Simply include the following somewhere after your Store definitions: 10 | 11 | ```ts 12 | import { registerInDevtools, Store } from "pullstate"; 13 | 14 | // Store definition 15 | const ExampleStore = new Store({ 16 | //... 17 | }); 18 | 19 | // Register as many or as few Stores as you would like to monitor in the devtools 20 | registerInDevtools({ 21 | ExampleStore, 22 | }); 23 | ``` 24 | 25 | After this, you should be able to open the Redux devtools tab and see each Store registered and showing changes. 26 | -------------------------------------------------------------------------------- /docs/subscribe.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: subscribe 3 | title: Subscribe 4 | sidebar_label: Subscribe 5 | --- 6 | 7 | Subscriptions are client-side only listeners to changes in your store's state. 8 | 9 | They are very similar to [Reactions](reactions.md), the difference being they only listen for state changes and send you the new state. Reactions allow you to react and change your store's state at the same time in a "batched" way. **Subscriptions only send you the new values.** The reason these two are separated is for performance reasons - if you do not need to react and change your store's state on an update, rather use subscriptions. 10 | 11 | Some uses include integrating with third-party tools, for times when you want to align your app's state changes with a change in the tool as well. 12 | 13 | ## Subscribe API 14 | 15 | ```tsx 16 | myStore.subscribe(watch, listener) => unsubscribe 17 | ``` 18 | 19 | **First** argument `watch` - a function which selects the state you'd like to watch for changes: 20 | 21 | ``` 22 | storeState => storeState.valueToWatch; 23 | ``` 24 | 25 | **Second** argument `listener` - a callback function which is run when the watched value changes. The new watched value will be the first argument, and the entire store's state is the second. The third argument is the last watched value, passed as a convenience for if you ever need to refer to it. 26 | 27 | ```jsx 28 | (watched, allState, prevWatched) => { //do things }; 29 | ``` 30 | 31 | **Return** value `unsubscribe` is simply a function you can run in order to stop listening. 32 | 33 | ## Subscribe Examples 34 | 35 | ### Leaflet tile change 36 | 37 | _(Listening for an option change in our store to update a Leaflet tile layer's source)_ 38 | 39 | ```tsx 40 | // a useEffect() hook in a functional component 41 | 42 | useEffect(() => { 43 | const tileLayer = L.tileLayer(tileTemplate.url, { 44 | minZoom: 3, 45 | maxZoom: 18, 46 | }).addTo(mapRef.current); 47 | 48 | const unsubscribeFromTileTemplate = GISStore.subscribe( 49 | s => s.tileLayerTemplate, 50 | newTemplate => { 51 | tileLayer.setUrl(newTemplate.url); 52 | } 53 | ); 54 | 55 | return () => { 56 | unsubscribeFromTileTemplate(); 57 | }; 58 | }, []); 59 | ``` 60 | 61 | As you can see from the example, we listen to the `tileLayerTemplate` value in `GISStore`. If that changes, we want to set our Leaflet `tileLayer` to the new url, which will change the source of images used for our map's tiles. 62 | 63 | Also note that the value returned from `subscribe()` is a function. We should call this function to remove the listener when we don't need it anymore - here in the "cleanup" returned function of `useEffect()`. 64 | 65 | --- 66 | 67 | ### Firebase Realtime Database 68 | 69 | Another interesting use-case could be subscribing to a Firebase realtime data node, based on a certain value in your store. 70 | 71 | ```tsx 72 | let previousCityUnsubscribe = () => null; 73 | 74 | function startWatchingCity(cityCode) { 75 | return db.collection("cities") 76 | .doc(cityCode) 77 | .onSnapshot(function(doc) { 78 | CityStore.update(s => { 79 | s.watchedCities[cityCode] = { updated: new Date(), data: doc.data() }; 80 | }); 81 | }); 82 | } 83 | 84 | function getRealtimeUpdatesForCityCode(cityCode) { 85 | previousCityUnsubscribe(); 86 | previousCityUnsubscribe = startWatchingCity(cityCode); 87 | } 88 | 89 | CityStore.subscribe(s => s.currentCityCode, getRealtimeUpdatesForCityCode); 90 | 91 | // inside some initialization code on the client (run after initial 92 | // store hydration, if any), start watching initial city 93 | function initializeClientThings() { 94 | getRealtimeUpdatesForCityCode(CityStore.getRawState().currentCityCode); 95 | } 96 | ``` 97 | 98 | And now, any time you run an update somewhere in your app: 99 | ```tsx 100 | CityStore.update(s => { 101 | s.currentCityCode = newCode; 102 | }) 103 | ``` 104 | 105 | If the value has actually changed, the current realtime database listener should be unsubscribed and a new one created with the new `currentCityCode`. -------------------------------------------------------------------------------- /docs/update-store.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: update-store 3 | title: update() 4 | sidebar_label: update() 5 | --- 6 | 7 | You can update your store's state by calling `update()` directly on your store with an updater function passed in: 8 | 9 | ```tsx 10 | MyStore.update(updater | updater[]) 11 | ``` 12 | 13 | The updater function is simply a function which takes the store's current state and allows you to mutate it directly to create the next state. This is thanks to the power of [immer](https://github.com/immerjs/immer). 14 | 15 | Notice here that you can also pass multiple updater functions to `update()`. This allows us to be more modular with our store updates, and combine different updates together in a single batch. 16 | 17 | ### patches callback 18 | 19 | ```tsx 20 | MyStore.update(updater | updater[], patches) 21 | ``` 22 | 23 | An optional second argument to `update()` is a patch callback - this is a very useful API provided by `immer`, and since `update()` is pretty much just a wrapper around Immer's functionality, we provide a way for you to make use of it in your Pullstate updates too. Read more about patches in `immer` docs, [here](https://github.com/immerjs/immer#patches). 24 | 25 | Patches allow fun things such as undo / redo functionality and state time travel! 26 | 27 | ## Example for update() 28 | 29 | Add some basic interaction to your app with a ` 47 | 48 | ); 49 | ``` 50 | 51 | Notice how we call `update()` on `UIStore` and pass the updater function in. 52 | 53 | Another pattern, which helps to illustrate this further, would be to actually define the action of toggling dark mode to a function on its own: 54 | 55 | ```tsx 56 | function toggleMode(s) { 57 | s.isDarkMode = !s.isDarkMode; 58 | } 59 | 60 | // ...in our 62 | ``` 63 | 64 | Basically, to update our store's state all we need to do is create a function (inline arrow function or regular) which takes the current store's state and mutates it to whatever we'd like the next state to be. 65 | 66 | ## Example with Multiple Updaters 67 | 68 | As mentioned above, the `update()` method also allows you to pass in an array of multiple updaters. Allowing you to batch multiple, more modular, updates to your store: 69 | 70 | ```ts 71 | UIStore.update([setDarkMode, setTypography("Roboto)]); 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/use-store-state-hook.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: use-store-state-hook 3 | title: useStoreState (hook) 4 | sidebar_label: useStoreState (hook) 5 | --- 6 | 7 | > **NB** This code can all be used in a slightly different (and perhaps more readable) way, with `YourStore.useState()`. It functions exactly the same as the methods below, except without the first `store` parameter (since we are already calling it on the store itself). 8 | 9 | The `useStoreState()` hook to be used in your functional components has the following API interface: 10 | 11 | 12 | 13 | ```jsx 14 | function useStoreState(store); 15 | function useStoreState(store, getSubState); 16 | function useStoreState(store, getSubState, dependencies); 17 | ``` 18 | 19 | 20 | ```tsx 21 | function useStoreState(store: Store): S; 22 | function useStoreState(store: Store, getSubState: (state: S) => SS): SS; 23 | function useStoreState(store: Store, getSubState: (state: S) => SS, deps?: ReadonlyArray): SS; 24 | ``` 25 | 26 | * `S` here is an interface that represents your entire store's state 27 | * `SS` is an interface which represents a selected sub-state from your store 28 | 29 | 30 | 31 | Let's go through each way of using it: 32 | 33 | ## Use a store's entire state 34 | 35 | Example: 36 | 37 | ```tsx 38 | const allUIState = useStoreState(UIStore); 39 | 40 | return (allUIState.isDarkMode ? : ); 41 | ``` 42 | 43 | * A Pullstate store is passed in as the first argument 44 | * We do not provide any `getSubState()` selection method as the second argument 45 | * The entire store's state is returned 46 | * This way of using `useStoreState()` is not recommended generally, as smaller selections result in less re-rendering 47 | * **Our component will be re-rendered every time _any value_ changes in `UIStore`** 48 | 49 | The above (excluding store argument) applies to this method too: 50 | 51 | ```tsx 52 | const allUIState = UIStore.useState(); 53 | ``` 54 | 55 | ## Use a sub-state of a store 56 | 57 | Example: 58 | 59 | ```tsx 60 | const isDarkMode = useStoreState(UIStore, s => s.isDarkMode); 61 | 62 | return (isDarkMode ? : ); 63 | ``` 64 | 65 | * The recommended way of using `useStoreState()`, as smaller selections result in less re-rendering 66 | * A Pullstate store is passed in as the first argument 67 | * We provide a `getSubState()` selection method as the second argument 68 | * Here selecting `isDarkMode` inside our UIStore's state 69 | * **Our component will be re-rendered only if the value of `isDarkMode` has changed in our store** 70 | 71 | The above (excluding store argument) applies to this method too: 72 | 73 | ```tsx 74 | const isDarkMode = UIStore.useState(s => s.isDarkMode); 75 | ``` 76 | 77 | ### Return multiple values in sub-state 78 | 79 | ```tsx 80 | const { isDarkMode, isMobile } = useStoreState(UIStore, s => ({ 81 | isDarkMode: s.isDarkMode, 82 | isMobile: s.isMobile 83 | })); 84 | 85 | // OR 86 | 87 | const { isDarkMode, isMobile } = UIStore.useState(s => ({ 88 | isDarkMode: s.isDarkMode, 89 | isMobile: s.isMobile 90 | })); 91 | ``` 92 | 93 | ## Use a sub-state of a store, dynamically 94 | 95 | If you ever need to grab the sub-state of the store, using some kind of dynamic value inside your `getSubState()` selector function - then you need to provide the 3rd option to `useStoreState()` - an array of dependencies. 96 | 97 | This acts pretty much exactly the same as the `useEffect()` React hook, in that it will reassess our selection if the dependency array ever changes. 98 | 99 | You can make use of it like so: 100 | 101 | ```tsx 102 | const MyComponent = ({ type }) => { 103 | const data = useStoreState(MyStore, (s) => s[type], [type]); 104 | 105 | // OR 106 | 107 | const data = MyStore.useState((s) => s[type], [type]); 108 | 109 | // do stuff with data 110 | } 111 | ``` 112 | 113 | Now whenever the value of `type` changes, Pullstate will reassess our selection and we will pull the new value from our store correctly. 114 | -------------------------------------------------------------------------------- /graphics/Icon on Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/Icon on Light.png -------------------------------------------------------------------------------- /graphics/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/Icon.png -------------------------------------------------------------------------------- /graphics/logo-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/logo-new.png -------------------------------------------------------------------------------- /graphics/logo-newest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/logo-newest.png -------------------------------------------------------------------------------- /graphics/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/logo.png -------------------------------------------------------------------------------- /graphics/pullstate-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/2a4651a8e16f543b484298592cf894c281c98c16/graphics/pullstate-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pullstate", 3 | "version": "1.25.0", 4 | "description": "Simple state stores using immer and React hooks", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "esnext": "dist/index.es.js", 8 | "main:umd": "dist/pullstate.umd.js", 9 | "main:umd:min": "dist/pullstate.umd.min.js", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "generate-typedoc": "typedoc src/index.ts", 16 | "test": "jest", 17 | "test-watch": "jest --watch", 18 | "clean": "rimraf ./dist", 19 | "build": "npm run clean && rollup -c --bundleConfigAsCjs", 20 | "uglify": "terser ./dist/index.js -o ./dist/index.js", 21 | "check-size": "minified-size ./dist/index.es.js", 22 | "check-size-cjs": "minified-size ./dist/index.js", 23 | "benchmark-all": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/all.ts", 24 | "benchmark-async-argument": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/benchmark-async-argument.ts", 25 | "benchmark-destructuring": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/benchmark-destructuring.ts", 26 | "benchmark-all-immer": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/benchmark-all-immer.ts", 27 | "benchmark-immer-without-stores": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/benchmark-immer-without-stores.ts", 28 | "benchmark-immer-with-stores": "cross-env TS_NODE_PROJECT=./test/tsconfig.json node -r ts-node/register --max-old-space-size=4096 test/benchmark/benchmark-immer-with-stores.ts" 29 | }, 30 | "keywords": [ 31 | "immer", 32 | "state", 33 | "store", 34 | "react", 35 | "hooks" 36 | ], 37 | "author": "Paul Myburgh", 38 | "license": "MIT", 39 | "dependencies": { 40 | "fast-deep-equal": "^3.1.3", 41 | "immer": "^9.0.16" 42 | }, 43 | "repository": "https://github.com/lostpebble/pullstate", 44 | "devDependencies": { 45 | "@testing-library/jest-dom": "^5.16.5", 46 | "@testing-library/react": "^13.4.0", 47 | "@types/benchmark": "^2.1.2", 48 | "@types/jest": "29.2.3", 49 | "@types/lodash": "^4.14.190", 50 | "@types/react": "18.0.25", 51 | "@types/react-dom": "18.0.9", 52 | "benchmark": "^2.1.4", 53 | "cross-env": "^7.0.3", 54 | "in-publish": "^2.0.1", 55 | "jest": "29.3.1", 56 | "jest-dom": "^4.0.0", 57 | "jest-environment-jsdom": "29.3.1", 58 | "jest-environment-jsdom-global": "4.0.0", 59 | "js-beautify": "^1.14.7", 60 | "lodash": "^4.17.21", 61 | "minified-size": "^3.0.0", 62 | "prettier": "^2.8.0", 63 | "react-test-renderer": "^18.2.0", 64 | "@rollup/plugin-commonjs": "^23.0.3", 65 | "@rollup/plugin-typescript": "^10.0.0", 66 | "@rollup/plugin-node-resolve": "^15.0.1", 67 | "react": "^18.2.0", 68 | "react-dom": "^18.2.0", 69 | "rollup-plugin-typescript2": "^0.34.1", 70 | "rollup-plugin-terser": "^7.0.2", 71 | "rollup": "^3.5.0", 72 | "terser": "^3.16.1", 73 | "ts-jest": "^29.0.3", 74 | "ts-loader": "^9.4.1", 75 | "ts-node": "^10.9.1", 76 | "typedoc": "^0.23.21", 77 | "type-fest": "^3.3.0", 78 | "typescript": "4.9.3", 79 | "webpack": "^4.44.2", 80 | "webpack-cli": "^3.3.12" 81 | }, 82 | "peerDependencies": { 83 | "react": "^16.12.0 || ^17.0.0 || ^18.0.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import { terser } from "rollup-plugin-terser"; 3 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 4 | import commonjs from "@rollup/plugin-commonjs"; 5 | import pkg from "./package.json" assert { type: "json" }; 6 | // import typescript from "@rollup/plugin-typescript"; 7 | 8 | export default [{ 9 | input: "./src/index.ts", 10 | plugins: [ 11 | typescript({ 12 | typescript: require("typescript"), 13 | }), 14 | ], 15 | output: [ 16 | { 17 | file: pkg.main, 18 | format: "cjs", 19 | compact: true, 20 | // dir: "dist" 21 | }, 22 | { 23 | file: pkg.module, 24 | format: "es", 25 | compact: true, 26 | // dist: "dist" 27 | }, 28 | ], 29 | external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], 30 | }, { 31 | input: "./src/index.ts", 32 | plugins: [ 33 | typescript({ 34 | typescript: require("typescript"), 35 | }), 36 | nodeResolve(), 37 | commonjs(), 38 | ], 39 | output: [ 40 | { 41 | file: pkg["main:umd"], 42 | format: "umd", 43 | name: "pullstate", 44 | globals: { 45 | "react": "React", 46 | "immer": "immer", 47 | }, 48 | }, 49 | { 50 | file: pkg["main:umd:min"], 51 | format: "umd", 52 | name: "pullstate", 53 | globals: { 54 | "react": "React", 55 | "immer": "immer", 56 | }, 57 | plugins: [terser()], 58 | }, 59 | ], 60 | external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})].filter(dep => dep !== "fast-deep-equal"), 61 | }]; 62 | -------------------------------------------------------------------------------- /src/InjectAsyncAction.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | IAsyncActionBeckonOptions, 4 | IAsyncActionWatchOptions, 5 | IOCreateAsyncActionOutput, 6 | TPullstateAsyncBeckonResponse, 7 | TPullstateAsyncWatchResponse 8 | } from "./async-types"; 9 | import { IPullstateAllStores } from "./PullstateCore"; 10 | 11 | export enum EAsyncActionInjectType { 12 | WATCH = "watch", 13 | BECKON = "beckon", 14 | } 15 | 16 | interface IPropsInjectAsyncActionBase { 17 | action: IOCreateAsyncActionOutput; 18 | args?: A; 19 | } 20 | 21 | export interface IPropsInjectAsyncActionBeckon 22 | extends IPropsInjectAsyncActionBase { 23 | type: EAsyncActionInjectType.BECKON; 24 | options?: IAsyncActionBeckonOptions; 25 | children: (response: TPullstateAsyncBeckonResponse) => React.ReactElement; 26 | } 27 | 28 | export interface IPropsInjectAsyncActionWatch 29 | extends IPropsInjectAsyncActionBase { 30 | type: EAsyncActionInjectType.WATCH; 31 | children: (response: TPullstateAsyncWatchResponse) => React.ReactElement; 32 | options?: IAsyncActionWatchOptions; 33 | } 34 | 35 | export type TInjectAsyncActionProps = IPropsInjectAsyncActionBeckon | IPropsInjectAsyncActionWatch; 36 | 37 | export function InjectAsyncAction( 38 | props: TInjectAsyncActionProps 39 | ): React.ReactElement { 40 | if (props.type === EAsyncActionInjectType.BECKON) { 41 | const response = props.action.useBeckon(props.args, props.options); 42 | return props.children(response); 43 | } 44 | 45 | const response = props.action.useWatch(props.args, props.options); 46 | return props.children(response); 47 | } 48 | -------------------------------------------------------------------------------- /src/InjectStoreState.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Store } from "./Store"; 3 | import { useStoreState } from "./useStoreState"; 4 | 5 | export interface IPropsInjectStoreState { 6 | store: Store; 7 | on?: (state: S) => SS; 8 | children: (output: SS) => React.ReactElement; 9 | } 10 | 11 | export function InjectStoreState({ 12 | store, 13 | on = s => s as any, 14 | children, 15 | }: IPropsInjectStoreState): React.ReactElement { 16 | const state: SS = useStoreState(store, on); 17 | return children(state); 18 | } 19 | -------------------------------------------------------------------------------- /src/InjectStoreStateOpt.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Store } from "./Store"; 3 | import { useStoreStateOpt } from "./useStoreStateOpt"; 4 | import { ObjectPath } from "./useStoreStateOpt-types"; 5 | import type { GetWithPath } from "type-fest/get"; 6 | 7 | export interface IPropsInjectStoreStateOpt< 8 | T extends readonly unknown[], 9 | S extends object = object, 10 | P extends ObjectPath = T extends ObjectPath ? T : never 11 | > { 12 | store: Store; 13 | paths: P; 14 | children: (output: GetWithPath) => React.ReactElement; 15 | } 16 | 17 | /* 18 | import { DeepTypeOfArray, TAllPathsParameter } from "./useStoreStateOpt-types"; 19 | 20 | export interface IPropsInjectStoreStateOpt< 21 | S extends object = object, 22 | P extends TAllPathsParameter = TAllPathsParameter, 23 | O extends [ 24 | DeepTypeOfArray, 25 | DeepTypeOfArray, 26 | DeepTypeOfArray, 27 | DeepTypeOfArray, 28 | DeepTypeOfArray, 29 | DeepTypeOfArray, 30 | DeepTypeOfArray, 31 | DeepTypeOfArray, 32 | DeepTypeOfArray, 33 | DeepTypeOfArray, 34 | DeepTypeOfArray 35 | ] = [ 36 | DeepTypeOfArray, 37 | DeepTypeOfArray, 38 | DeepTypeOfArray, 39 | DeepTypeOfArray, 40 | DeepTypeOfArray, 41 | DeepTypeOfArray, 42 | DeepTypeOfArray, 43 | DeepTypeOfArray, 44 | DeepTypeOfArray, 45 | DeepTypeOfArray, 46 | DeepTypeOfArray 47 | ] 48 | > { 49 | store: Store; 50 | paths: P; 51 | children: (output: O) => React.ReactElement; 52 | }*/ 53 | 54 | export function InjectStoreStateOpt< 55 | T extends readonly unknown[], 56 | S extends object = object, 57 | P extends ObjectPath = T extends ObjectPath ? T : never 58 | >({ store, paths, children }: IPropsInjectStoreStateOpt): React.ReactElement { 59 | const state = useStoreStateOpt(store, paths) as GetWithPath; 60 | return children(state); 61 | } 62 | -------------------------------------------------------------------------------- /src/PullstateCore.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Store, TUpdateFunction } from "./Store"; 3 | import { clientAsyncCache, createAsyncAction, createAsyncActionDirect } from "./async"; 4 | import { 5 | IAsyncActionRunOptions, 6 | ICreateAsyncActionOptions, 7 | IOCreateAsyncActionOutput, 8 | IPullstateAsyncActionOrdState, 9 | IPullstateAsyncCache, 10 | IPullstateAsyncResultState, 11 | TPullstateAsyncAction, 12 | TPullstateAsyncRunResponse 13 | } from "./async-types"; 14 | 15 | export interface IPullstateAllStores { 16 | [storeName: string]: Store; 17 | } 18 | 19 | export const PullstateContext = React.createContext | null>(null); 20 | 21 | export const PullstateProvider = ( 22 | { 23 | instance, 24 | children 25 | }: { 26 | instance: PullstateInstance; 27 | children?: any; 28 | }) => { 29 | return {children}; 30 | }; 31 | 32 | let singleton: PullstateSingleton | null = null; 33 | 34 | export const clientStores: { 35 | internalClientStores: true; 36 | stores: IPullstateAllStores; 37 | loaded: boolean; 38 | } = { 39 | internalClientStores: true, 40 | loaded: false, 41 | stores: {} 42 | }; 43 | 44 | export type TMultiStoreAction

Hi

11 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/tests/TestSetup.ts: -------------------------------------------------------------------------------- 1 | import { waitSeconds } from "./TestUtils"; 2 | import { createAsyncAction, createPullstateCore, Store, successResult } from "../../src"; 3 | 4 | const names = ["Paul", "Dave", "Michel"]; 5 | const userNames = ["lostpebble", "davej", "mweststrate"]; 6 | 7 | export interface IUser { 8 | name: string; 9 | userName: string; 10 | } 11 | 12 | export interface IUserStore { 13 | user: null | IUser; 14 | currentUserId: number; 15 | } 16 | 17 | export interface IOGetUserInput { 18 | userId?: number; 19 | } 20 | 21 | export function createTestBasics() { 22 | let currentUser = 0; 23 | 24 | const UserStore = new Store({ 25 | user: null, 26 | currentUserId: 0, 27 | }); 28 | 29 | async function getNewUserObject({ userId = -1 }: IOGetUserInput): Promise { 30 | currentUser = userId >= 0 ? userId : (currentUser + 1) % 3; 31 | 32 | await waitSeconds(1); 33 | 34 | return { 35 | name: names[currentUser], 36 | userName: userNames[currentUser], 37 | }; 38 | } 39 | 40 | const ChangeToNewUserAsyncAction = createAsyncAction(async opt => { 41 | return successResult(await getNewUserObject(opt)); 42 | }, { 43 | postActionHook: ({ result }) => { 44 | if (!result.error) { 45 | UserStore.update(s => { 46 | s.user = result.payload; 47 | }); 48 | } 49 | } 50 | }); 51 | 52 | const PullstateCore = createPullstateCore({ 53 | UserStore, 54 | }); 55 | 56 | return { 57 | UserStore, 58 | getNewUserObject, 59 | PullstateCore, 60 | ChangeToNewUserAsyncAction, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /test/tests/TestUtils.ts: -------------------------------------------------------------------------------- 1 | export async function waitSeconds(seconds: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, 1000 * seconds); 4 | }); 5 | } -------------------------------------------------------------------------------- /test/tests/__snapshots__/async.tests.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Async rendering renders our initial state with some pre-resolved async state 1`] = ` 4 | "
5 |

Async Test

6 |
User loaded 7 |
8 |

Hello, 9 | Dave

10 |

aka: 11 | davej

12 |
13 |
14 |
Got new user 15 |
16 |

Hello, 17 | Dave

18 |

aka: 19 | davej

20 |
21 |
22 |
" 23 | `; 24 | 25 | exports[`Async rendering renders our initial state without pre-resolved async 1`] = ` 26 | "
27 |

Async Test

28 |
Loading user
29 |
Getting new user
30 |
" 31 | `; 32 | -------------------------------------------------------------------------------- /test/tests/__snapshots__/client.tests.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Pullstate on client only Should be able to render its data 1`] = ` 4 | "
5 |

Some test

6 |
7 |

what what!

8 |
9 |

5

10 |
11 |

Count: 12 | 5

13 |
14 |
" 15 | `; 16 | -------------------------------------------------------------------------------- /test/tests/__snapshots__/ssr.tests.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Server Side Rendering tests Should be able to display data that's been changed on the server directly 1`] = ` 4 | "
5 |

Some test

6 |
7 |

hey there!

8 |
9 |

5

10 |
5 - 11 |
12 |
" 13 | `; 14 | -------------------------------------------------------------------------------- /test/tests/async.tests.tsx: -------------------------------------------------------------------------------- 1 | import { useStoreState } from "../../src/useStoreState"; 2 | import React from "react"; 3 | import ReactDOMServer from "react-dom/server"; 4 | import { PullstateProvider, Store } from "../../src"; 5 | import { createTestBasics, IOGetUserInput, IUserStore } from "./TestSetup"; 6 | import { IOCreateAsyncActionOutput } from "../../src/async-types"; 7 | 8 | const beautifyHtml = require("js-beautify").html; 9 | 10 | interface ITestProps { 11 | UserStore: Store; 12 | ChangeToNewUserAsyncAction: IOCreateAsyncActionOutput; 13 | } 14 | 15 | const UninitiatedUserAction = ({ UserStore, ChangeToNewUserAsyncAction }: ITestProps) => { 16 | // const [userId, setUserId] = useState(0); 17 | const { user, userId } = useStoreState(UserStore, s => ({ user: s.user, userId: s.currentUserId })); 18 | const [started, finished, result, updating] = ChangeToNewUserAsyncAction.useWatch({ userId: 1 }); 19 | 20 | return ( 21 |
22 | 23 | {started ? (finished ? `Got new user` : `Getting new user`) : `Haven't initiated getting new user`} 24 | 25 | {user !== null && ( 26 |
27 |

Hello, {user.name}

28 |

aka: {user.userName}

29 |
30 | )} 31 | {!started && ( 32 | 37 | )} 38 |
39 | ); 40 | }; 41 | 42 | const InitiatedNextUser = ({ UserStore, ChangeToNewUserAsyncAction }: ITestProps) => { 43 | const user = useStoreState(UserStore, s => s.user); 44 | const [finished] = ChangeToNewUserAsyncAction.useBeckon({ userId: 1 }); 45 | 46 | return ( 47 |
48 | {finished ? `User loaded` : `Loading user`} 49 | {user !== null && ( 50 |
51 |

Hello, {user.name}

52 |

aka: {user.userName}

53 |
54 | )} 55 | 61 |
62 | ); 63 | }; 64 | 65 | const App = (props: ITestProps) => { 66 | return ( 67 |
68 |

Async Test

69 | 70 | 71 |
72 | ); 73 | }; 74 | 75 | describe("Async rendering", () => { 76 | it("renders our initial state without pre-resolved async", () => { 77 | const { ChangeToNewUserAsyncAction, UserStore } = createTestBasics(); 78 | 79 | const reactHtml = ReactDOMServer.renderToString(); 80 | expect(beautifyHtml(reactHtml)).toMatchSnapshot(); 81 | }); 82 | 83 | it("renders our initial state with some pre-resolved async state", async () => { 84 | const { ChangeToNewUserAsyncAction, UserStore, PullstateCore } = createTestBasics(); 85 | 86 | const instance = PullstateCore.instantiate({ ssr: false }); 87 | await instance.runAsyncAction(ChangeToNewUserAsyncAction, { userId: 1 }); 88 | 89 | const reactHtml = ReactDOMServer.renderToString( 90 | 91 | 92 | 93 | ); 94 | expect(beautifyHtml(reactHtml)).toMatchSnapshot(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/tests/basic-store.tests.tsx: -------------------------------------------------------------------------------- 1 | import { setupBatch, Store } from "../../src"; 2 | 3 | interface ITestStore { 4 | eggs: string[]; 5 | touched: boolean; 6 | } 7 | 8 | function getNewStore(): Store { 9 | return new Store({ 10 | eggs: ["green"], 11 | touched: false, 12 | }); 13 | } 14 | 15 | describe("Store operations", () => { 16 | it("should be able to subscribe to changes", () => { 17 | const store = getNewStore(); 18 | 19 | const mockSubscribe = jest.fn(); 20 | 21 | store.subscribe(s => s.touched, mockSubscribe); 22 | 23 | store.update(s => { 24 | s.touched = true; 25 | }); 26 | 27 | store.update(s => { 28 | s.touched = false; 29 | }); 30 | 31 | expect(mockSubscribe.mock.calls.length).toBe(2); 32 | expect(mockSubscribe.mock.calls[0][0]).toBe(true); 33 | }); 34 | 35 | it("Should give the previous value when subscription gets a new value", () => { 36 | const store = getNewStore(); 37 | 38 | store.subscribe(s => s.touched, (watched, all, prevWatched) => { 39 | expect(watched).toEqual(true); 40 | expect(prevWatched).toEqual(false); 41 | }); 42 | 43 | store.update(s => { 44 | s.touched = true; 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/tests/client.tests.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from "react-dom/server"; 2 | import React from "react"; 3 | import { InjectStoreState, update, useStoreState } from "../../src/index"; 4 | import { TestUIStore } from "./testStores/TestUIStore"; 5 | const beautify = require("js-beautify").html; 6 | 7 | const Counter = () => { 8 | const count = useStoreState(TestUIStore, s => s.count); 9 | 10 | return ( 11 |
12 |

Count: {count}

13 | 22 |
23 | ); 24 | }; 25 | 26 | const App = () => { 27 | return ( 28 |
29 |

Some test

30 | s.message}> 31 | {message => ( 32 |
33 |

{message}

34 | 36 | TestUIStore.update(s => { 37 | s.message = e.target.value; 38 | }) 39 | } 40 | value={message} 41 | /> 42 |
43 | )} 44 |
45 | {uiStore =>

{uiStore.count}

}
46 | 47 |
48 | ); 49 | }; 50 | 51 | describe("Pullstate on client only", () => { 52 | const ReactApp = ; 53 | 54 | it("Should be able to render its data", () => { 55 | expect(beautify(ReactDOMServer.renderToString(ReactApp))).toMatchSnapshot(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/tests/core.tests.tsx: -------------------------------------------------------------------------------- 1 | import { createPullstateCore } from "../../src/index"; 2 | import { TestUIStore } from "./testStores/TestUIStore"; 3 | 4 | describe("createPullstateCore", () => { 5 | it("Should be able to be created without any stores", () => { 6 | expect(createPullstateCore()).toBeTruthy(); 7 | }); 8 | 9 | it("Should be able to be created with a store", () => { 10 | expect(createPullstateCore({ TestUIStore })).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/tests/opt-state-listeners.tests.tsx: -------------------------------------------------------------------------------- 1 | import { Store, useStoreStateOpt } from "../../src"; 2 | import { ITestUIStore } from "./testStores/TestUIStore"; 3 | 4 | const ListenerParent = ({ store }: { store: Store }) => { 5 | const [berries] = useStoreStateOpt(store, [["internal", "berries"]]); 6 | 7 | // const bool: boolean = berries; 8 | } 9 | 10 | describe("Optimized state listeners", () => { 11 | it("Should be able to pull basic state", () => { 12 | // expect() 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/tests/ssr.async.tests.tsx: -------------------------------------------------------------------------------- 1 | import { createAsyncAction, createPullstateCore, successResult } from "../../src"; 2 | 3 | const beautifyHtml = require("js-beautify").html; 4 | 5 | /*const HydrateNewUserAction = PullstateCore.createAsyncAction(async (_, { UserStore }) => { 6 | const newUser = await getUser(); 7 | UserStore.update(s => { 8 | s.user = newUser; 9 | }); 10 | return successResult(); 11 | }); 12 | 13 | const GetUserAction = PullstateCore.createAsyncAction(async ({ userId }, { UserStore }) => { 14 | const user = await UserApi.getUser(userId); 15 | UserStore.update(s => { 16 | s.user = user; 17 | }); 18 | return successResult(); 19 | });*/ 20 | 21 | describe("Server-side rendering Async Tests", () => { 22 | it("has no test yet", () => { 23 | expect(true).toEqual(true); 24 | }); 25 | }); 26 | 27 | describe("It Should be able to hydrate async state previously resolved, on first render", () => { 28 | 29 | }); 30 | -------------------------------------------------------------------------------- /test/tests/ssr.tests.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | createPullstateCore, 4 | InjectStoreState, IPullstateInstanceConsumable, 5 | PullstateProvider, 6 | Store, 7 | update, 8 | useStoreState, 9 | } from "../../src/index"; 10 | import ReactDOMServer from "react-dom/server"; 11 | import { ITestUIStore, TestUIStore } from "./testStores/TestUIStore"; 12 | const beautify = require('js-beautify').html; 13 | 14 | const PullstateCore = createPullstateCore({ TestUIStore }); 15 | 16 | const Counter = () => { 17 | const { TestUIStore: ui } = PullstateCore.useStores(); 18 | const count = useStoreState(ui, s => s.count); 19 | 20 | return ( 21 |
22 | {count} -{" "} 23 | 32 |
33 | ); 34 | }; 35 | 36 | const App = () => { 37 | const { TestUIStore: ui } = PullstateCore.useStores(); 38 | 39 | return ( 40 |
41 |

Some test

42 | s.message}> 43 | {message => ( 44 |
45 |

{message}

46 | 48 | update(ui, s => { 49 | s.message = e.target.value; 50 | }) 51 | } 52 | value={message} 53 | /> 54 |
55 | )} 56 |
57 | {uiStore =>

{uiStore.count}

}
58 | 59 |
60 | ); 61 | }; 62 | 63 | describe("Server Side Rendering tests", () => { 64 | const instance = PullstateCore.instantiate({ ssr: true }); 65 | 66 | instance.stores.TestUIStore.update(s => { 67 | s.message = "hey there!"; 68 | }); 69 | 70 | const ReactApp = ( 71 | 72 | 73 | 74 | ); 75 | 76 | it("Should be able to display data that's been changed on the server directly", () => { 77 | expect(beautify(ReactDOMServer.renderToString(ReactApp))).toMatchSnapshot(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/tests/testStores/TestUIStore.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "../../../src/Store"; 2 | 3 | export interface ITestUIStore { 4 | count: number; 5 | message: string; 6 | internal: { 7 | lekker: boolean; 8 | berries: string[]; 9 | }; 10 | } 11 | 12 | export const TestUIStore = new Store({ 13 | count: 5, 14 | message: "what what!", 15 | internal: { 16 | lekker: true, 17 | berries: ["blue", "red", "black"], 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "target": "esnext", 7 | "sourceMap": true, 8 | "sourceRoot": "./", 9 | "rootDir": "./", 10 | "inlineSourceMap": false, 11 | "declaration": true, 12 | "removeComments": true 13 | }, 14 | "exclude": [ 15 | "node_modules" 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "esnext", 5 | "esModuleInterop": true, 6 | "target": "ES2019", 7 | "sourceMap": true, 8 | "inlineSourceMap": false, 9 | "sourceRoot": "src", 10 | "rootDir": "./src", 11 | "declarationDir": "./dist", 12 | "declaration": true, 13 | "removeComments": true, 14 | "strict": true, 15 | "lib": [ 16 | "dom" 17 | ], 18 | "moduleResolution": "Node" 19 | }, 20 | "exclude": [ 21 | "./test", 22 | "website" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tsconfig": "./typedoc.tsconfig.json", 3 | "mode": "file", 4 | "out": "type-docs", 5 | "excludePrivate": true, 6 | "excludeNotExported": true, 7 | "stripInternal": true 8 | } 9 | -------------------------------------------------------------------------------- /typedoc.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "esnext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "target": "ES2019", 8 | "rootDir": "./src", 9 | "declarationDir": "./dist", 10 | "declaration": true, 11 | "removeComments": true, 12 | "strict": true, 13 | "lib": [ 14 | "dom" 15 | ] 16 | }, 17 | "exclude": [ 18 | "./test" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | This website was created with [Docusaurus](https://docusaurus.io/). 2 | 3 | # What's In This Document 4 | 5 | * [Get Started in 5 Minutes](#get-started-in-5-minutes) 6 | * [Directory Structure](#directory-structure) 7 | * [Editing Content](#editing-content) 8 | * [Adding Content](#adding-content) 9 | * [Full Documentation](#full-documentation) 10 | 11 | # Get Started in 5 Minutes 12 | 13 | 1. Make sure all the dependencies for the website are installed: 14 | 15 | ```sh 16 | # Install dependencies 17 | $ yarn 18 | ``` 19 | 2. Run your dev server: 20 | 21 | ```sh 22 | # Start the site 23 | $ yarn start 24 | ``` 25 | 26 | ## Directory Structure 27 | 28 | Your project file structure should look something like this 29 | 30 | ``` 31 | my-docusaurus/ 32 | docs/ 33 | doc-1.md 34 | doc-2.md 35 | doc-3.md 36 | website/ 37 | blog/ 38 | 2016-3-11-oldest-post.md 39 | 2017-10-24-newest-post.md 40 | core/ 41 | node_modules/ 42 | pages/ 43 | static/ 44 | css/ 45 | img/ 46 | package.json 47 | sidebar.json 48 | siteConfig.js 49 | ``` 50 | 51 | # Editing Content 52 | 53 | ## Editing an existing docs page 54 | 55 | Edit docs by navigating to `docs/` and editing the corresponding document: 56 | 57 | `docs/doc-to-be-edited.md` 58 | 59 | ```markdown 60 | --- 61 | id: page-needs-edit 62 | title: This Doc Needs To Be Edited 63 | --- 64 | 65 | Edit me... 66 | ``` 67 | 68 | For more information about docs, click [here](https://docusaurus.io/docs/en/navigation) 69 | 70 | ## Editing an existing blog post 71 | 72 | Edit blog posts by navigating to `website/blog` and editing the corresponding post: 73 | 74 | `website/blog/post-to-be-edited.md` 75 | ```markdown 76 | --- 77 | id: post-needs-edit 78 | title: This Blog Post Needs To Be Edited 79 | --- 80 | 81 | Edit me... 82 | ``` 83 | 84 | For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) 85 | 86 | # Adding Content 87 | 88 | ## Adding a new docs page to an existing sidebar 89 | 90 | 1. Create the doc as a new markdown file in `/docs`, example `docs/newly-created-doc.md`: 91 | 92 | ```md 93 | --- 94 | id: newly-created-doc 95 | title: This Doc Needs To Be Edited 96 | --- 97 | 98 | My new content here.. 99 | ``` 100 | 101 | 1. Refer to that doc's ID in an existing sidebar in `website/sidebar.json`: 102 | 103 | ```javascript 104 | // Add newly-created-doc to the Getting Started category of docs 105 | { 106 | "docs": { 107 | "Getting Started": [ 108 | "quick-start", 109 | "newly-created-doc" // new doc here 110 | ], 111 | ... 112 | }, 113 | ... 114 | } 115 | ``` 116 | 117 | For more information about adding new docs, click [here](https://docusaurus.io/docs/en/navigation) 118 | 119 | ## Adding a new blog post 120 | 121 | 1. Make sure there is a header link to your blog in `website/siteConfig.js`: 122 | 123 | `website/siteConfig.js` 124 | ```javascript 125 | headerLinks: [ 126 | ... 127 | { blog: true, label: 'Blog' }, 128 | ... 129 | ] 130 | ``` 131 | 132 | 2. Create the blog post with the format `YYYY-MM-DD-My-Blog-Post-Title.md` in `website/blog`: 133 | 134 | `website/blog/2018-05-21-New-Blog-Post.md` 135 | 136 | ```markdown 137 | --- 138 | author: Frank Li 139 | authorURL: https://twitter.com/foobarbaz 140 | authorFBID: 503283835 141 | title: New Blog Post 142 | --- 143 | 144 | Lorem Ipsum... 145 | ``` 146 | 147 | For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) 148 | 149 | ## Adding items to your site's top navigation bar 150 | 151 | 1. Add links to docs, custom pages or external links by editing the headerLinks field of `website/siteConfig.js`: 152 | 153 | `website/siteConfig.js` 154 | ```javascript 155 | { 156 | headerLinks: [ 157 | ... 158 | /* you can add docs */ 159 | { doc: 'my-examples', label: 'Examples' }, 160 | /* you can add custom pages */ 161 | { page: 'help', label: 'Help' }, 162 | /* you can add external links */ 163 | { href: 'https://github.com/facebook/Docusaurus', label: 'GitHub' }, 164 | ... 165 | ], 166 | ... 167 | } 168 | ``` 169 | 170 | For more information about the navigation bar, click [here](https://docusaurus.io/docs/en/navigation) 171 | 172 | ## Adding custom pages 173 | 174 | 1. Docusaurus uses React components to build pages. The components are saved as .js files in `website/pages/en`: 175 | 1. If you want your page to show up in your navigation header, you will need to update `website/siteConfig.js` to add to the `headerLinks` element: 176 | 177 | `website/siteConfig.js` 178 | ```javascript 179 | { 180 | headerLinks: [ 181 | ... 182 | { page: 'my-new-custom-page', label: 'My New Custom Page' }, 183 | ... 184 | ], 185 | ... 186 | } 187 | ``` 188 | 189 | For more information about custom pages, click [here](https://docusaurus.io/docs/en/custom-pages). 190 | 191 | # Full Documentation 192 | 193 | Full documentation can be found on the [website](https://docusaurus.io/). 194 | -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | const docsUrl = this.props.config.docsUrl; 14 | const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; 15 | const langPart = `${language ? `${language}/` : ''}`; 16 | return `${baseUrl}${docsPart}${langPart}${doc}`; 17 | } 18 | 19 | pageUrl(doc, language) { 20 | const baseUrl = this.props.config.baseUrl; 21 | return baseUrl + (language ? `${language}/` : '') + doc; 22 | } 23 | 24 | render() { 25 | return ( 26 |
69 | ); 70 | } 71 | } 72 | 73 | module.exports = Footer; 74 | -------------------------------------------------------------------------------- /website/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "This file is auto-generated by write-translations.js", 3 | "localized-strings": { 4 | "next": "Next", 5 | "previous": "Previous", 6 | "tagline": "Simple state stores using immer and React hooks", 7 | "docs": { 8 | "_removed": { 9 | "title": "_removed" 10 | }, 11 | "async-action-use": { 12 | "title": "Ways to make use of Async Actions", 13 | "sidebar_label": "Use Async Actions" 14 | }, 15 | "async-actions-creating": { 16 | "title": "Creating an Async Action", 17 | "sidebar_label": "Creating an Async Action" 18 | }, 19 | "async-actions-introduction": { 20 | "title": "Introduction to Async Actions", 21 | "sidebar_label": "Introduction" 22 | }, 23 | "async-actions-other-options": { 24 | "title": "Other Async Action Options", 25 | "sidebar_label": "Other Async Action Options" 26 | }, 27 | "async-cache-break-hook": { 28 | "title": "Cache break hook", 29 | "sidebar_label": "Cache break hook" 30 | }, 31 | "async-cache-clearing": { 32 | "title": "Async cache clearing", 33 | "sidebar_label": "Cache clearing" 34 | }, 35 | "async-hooks-overview": { 36 | "title": "Async hooks overview", 37 | "sidebar_label": "Hooks overview" 38 | }, 39 | "async-post-action-hook": { 40 | "title": "Post action hook", 41 | "sidebar_label": "Post action hook" 42 | }, 43 | "async-server-rendering": { 44 | "title": "Resolving async state while server-rendering", 45 | "sidebar_label": "Resolve async state on the server" 46 | }, 47 | "async-short-circuit-hook": { 48 | "title": "Short circuit hook", 49 | "sidebar_label": "Short circuit hook" 50 | }, 51 | "inject-store-state": { 52 | "title": "", 53 | "sidebar_label": "" 54 | }, 55 | "installation": { 56 | "title": "Installation", 57 | "sidebar_label": "Installation" 58 | }, 59 | "quick-example-server-rendered": { 60 | "title": "Quick example (server rendering)", 61 | "sidebar_label": "Quick example (server rendering)" 62 | }, 63 | "quick-example": { 64 | "title": "Quick example", 65 | "sidebar_label": "Quick example" 66 | }, 67 | "reactions": { 68 | "title": "Reactions", 69 | "sidebar_label": "Reactions" 70 | }, 71 | "redux-dev-tools": { 72 | "title": "Redux Devtools", 73 | "sidebar_label": "Redux Devtools" 74 | }, 75 | "subscribe": { 76 | "title": "Subscribe", 77 | "sidebar_label": "Subscribe" 78 | }, 79 | "update-store": { 80 | "title": "update()", 81 | "sidebar_label": "update()" 82 | }, 83 | "use-store-state-hook": { 84 | "title": "useStoreState (hook)", 85 | "sidebar_label": "useStoreState (hook)" 86 | } 87 | }, 88 | "links": { 89 | "Docs": "Docs", 90 | "GitHub": "GitHub" 91 | }, 92 | "categories": { 93 | "Getting Started": "Getting Started", 94 | "Reading Store State": "Reading Store State", 95 | "Updating Store State": "Updating Store State", 96 | "Async Actions": "Async Actions", 97 | "Dev Tools": "Dev Tools" 98 | } 99 | }, 100 | "pages-strings": { 101 | "Help Translate|recruit community translators for your project": "Help Translate", 102 | "Edit this Doc|recruitment message asking to edit the doc source": "Edit", 103 | "Translate this Doc|recruitment message asking to translate the docs": "Translate" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "cross-env GIT_USER=lostpebble docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "2.0.0-alpha.65", 13 | "cross-env": "^7.0.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /website/pages/en/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require("react"); 9 | 10 | const CompLibrary = require("../../core/CompLibrary.js"); 11 | 12 | const MarkdownBlock = CompLibrary.MarkdownBlock; /* Used to read markdown */ 13 | const Container = CompLibrary.Container; 14 | const GridBlock = CompLibrary.GridBlock; 15 | 16 | class HomeSplash extends React.Component { 17 | render() { 18 | const { siteConfig, language = "" } = this.props; 19 | const { baseUrl, docsUrl } = siteConfig; 20 | const docsPart = `${docsUrl ? `${docsUrl}/` : ""}`; 21 | const langPart = `${language ? `${language}/` : ""}`; 22 | const docUrl = doc => `${baseUrl}${docsPart}${langPart}${doc}`; 23 | 24 | const SplashContainer = props => ( 25 |
26 |
27 |
{props.children}
28 |
29 |
30 | ); 31 | 32 | const Logo = props => ( 33 |
34 | Project Logo 35 |
36 | ); 37 | 38 | const ProjectTitle = () => ( 39 |

40 | {siteConfig.title} 41 | {siteConfig.tagline} 42 |

43 | ); 44 | 45 | const PromoSection = props => ( 46 |
47 |
48 |
{props.children}
49 |
50 |
51 | ); 52 | 53 | const Button = props => ( 54 | 59 | ); 60 | 61 | return ( 62 | 63 | Project Logo 64 |
74 | {/**/} 75 |
76 |
77 | Ridiculously simple state stores with performant retrieval anywhere in your React tree using 78 | React hooks 79 |
80 | {/*
81 |
82 | 83 | Version 1.2.0 released! 84 | 85 |
86 |
87 | API settled, and new documentation site live 88 |
89 |
*/} 90 |
91 | 92 | Featuring easy async state handling too! 93 | 94 |
95 | 96 |
97 |
98 |
99 | 100 | ); 101 | } 102 | } 103 | 104 | class Index extends React.Component { 105 | render() { 106 | const { config: siteConfig, language = "" } = this.props; 107 | 108 | return ; 109 | } 110 | } 111 | 112 | module.exports = Index; 113 | -------------------------------------------------------------------------------- /website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "Getting Started": [ 4 | "installation", 5 | "quick-example", 6 | "quick-example-server-rendered" 7 | ], 8 | "Reading Store State": [ 9 | "use-store-state-hook", 10 | "inject-store-state", 11 | "subscribe" 12 | ], 13 | "Updating Store State": [ 14 | "update-store", 15 | "reactions" 16 | ], 17 | "Async Actions": [ 18 | "async-actions-introduction", 19 | "async-actions-creating", 20 | "async-action-use", 21 | { 22 | "type": "subcategory", 23 | "label": "Async Action Hooks", 24 | "ids": [ 25 | "async-hooks-overview", 26 | "async-post-action-hook", 27 | "async-short-circuit-hook", 28 | "async-cache-break-hook" 29 | ] 30 | }, 31 | "async-actions-other-options", 32 | "async-cache-clearing", 33 | "async-server-rendering" 34 | ], 35 | "Dev Tools": ["redux-dev-tools"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /website/siteConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | // See https://docusaurus.io/docs/site-config for all the possible 9 | // site configuration options. 10 | 11 | // List of projects/orgs using your project for the users page. 12 | /*const users = [ 13 | { 14 | caption: 'User1', 15 | // You will need to prepend the image path with your baseUrl 16 | // if it is not '/', like: '/test-site/img/docusaurus.svg'. 17 | image: '/img/docusaurus.svg', 18 | infoLink: 'https://www.facebook.com', 19 | pinned: true, 20 | }, 21 | ];*/ 22 | 23 | const siteConfig = { 24 | title: "Pullstate", // Title for your website. 25 | tagline: "Simple state stores using immer and React hooks", 26 | url: "https://lostpebble.github.io", // Your website URL 27 | baseUrl: "/pullstate/", // Base URL for your project */ 28 | // For github.io type URLs, you would set the url and baseUrl like: 29 | // url: 'https://facebook.github.io', 30 | // baseUrl: '/test-site/', 31 | 32 | // Used for publishing and more 33 | projectName: "pullstate", 34 | organizationName: "lostpebble", 35 | // For top-level user or org sites, the organization is still the same. 36 | // e.g., for the https://JoelMarcey.github.io site, it would be set like... 37 | // organizationName: 'JoelMarcey' 38 | 39 | // For no header links in the top nav bar -> headerLinks: [], 40 | headerLinks: [{ doc: "quick-example", label: "Docs" }, { href: 'https://github.com/lostpebble/pullstate', label: 'GitHub' }], 41 | 42 | // If you have users set above, you add it here: 43 | // users, 44 | 45 | /* path to images for header/footer */ 46 | headerIcon: "img/icon-transparent-ondark-new.png", 47 | footerIcon: "img/icon-transparent-ondark-new.png", 48 | favicon: "img/icon-transparent-onlight.png", 49 | 50 | /* Colors for website */ 51 | colors: { 52 | primaryColor: "#7c8ef1", 53 | secondaryColor: "#375979", 54 | }, 55 | 56 | /* Custom fonts for website */ 57 | /* 58 | fonts: { 59 | myFont: [ 60 | "Times New Roman", 61 | "Serif" 62 | ], 63 | myOtherFont: [ 64 | "-apple-system", 65 | "system-ui" 66 | ] 67 | }, 68 | */ 69 | 70 | // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. 71 | copyright: `Created by Paul Myburgh`, 72 | 73 | /*highlight: { 74 | // Highlight.js theme to use for syntax highlighting in code blocks. 75 | theme: "default", 76 | },*/ 77 | 78 | usePrism: ["tsx", "jsx"], 79 | 80 | // Add custom scripts here that would be placed in

, 45 | S extends IPullstateAllStores = P extends PullstateSingleton ? ST : any> = (update: TMultiStoreUpdateMap) => void; 46 | 47 | interface IPullstateSingletonOptions { 48 | asyncActions?: { 49 | defaultCachingSeconds?: number; 50 | }; 51 | } 52 | 53 | export class PullstateSingleton { 54 | // private readonly originStores: S = {} as S; 55 | // private updatedStoresInAct = new Set(); 56 | // private actUpdateMap: TMultiStoreUpdateMap | undefined; 57 | options: IPullstateSingletonOptions = {}; 58 | 59 | constructor(allStores: S, options: IPullstateSingletonOptions = {}) { 60 | if (singleton !== null) { 61 | console.error( 62 | `Pullstate: createPullstate() - Should not be creating the core Pullstate class more than once! In order to re-use pull state, you need to call instantiate() on your already created object.` 63 | ); 64 | } 65 | 66 | singleton = this; 67 | // this.originStores = allStores; 68 | clientStores.stores = allStores; 69 | clientStores.loaded = true; 70 | this.options = options; 71 | } 72 | 73 | instantiate( 74 | { 75 | hydrateSnapshot, 76 | ssr = false, 77 | customContext 78 | }: { hydrateSnapshot?: IPullstateSnapshot; ssr?: boolean, customContext?: any } = {}): PullstateInstance { 79 | if (!ssr) { 80 | const instantiated = new PullstateInstance(clientStores.stores as S, false, customContext); 81 | 82 | if (hydrateSnapshot != null) { 83 | instantiated.hydrateFromSnapshot(hydrateSnapshot); 84 | } 85 | 86 | instantiated.instantiateReactions(); 87 | return instantiated as PullstateInstance; 88 | } 89 | 90 | const newStores: IPullstateAllStores = {}; 91 | 92 | for (const storeName of Object.keys(clientStores.stores)) { 93 | if (hydrateSnapshot == null) { 94 | newStores[storeName] = new Store(clientStores.stores[storeName]._getInitialState()); 95 | } else if (hydrateSnapshot.hasOwnProperty(storeName)) { 96 | newStores[storeName] = new Store(hydrateSnapshot.allState[storeName]); 97 | } else { 98 | newStores[storeName] = new Store(clientStores.stores[storeName]._getInitialState()); 99 | console.warn( 100 | `Pullstate (instantiate): store [${storeName}] didn't hydrate any state (data was non-existent on hydration object)` 101 | ); 102 | } 103 | 104 | newStores[storeName]._setInternalOptions({ 105 | ssr, 106 | reactionCreators: clientStores.stores[storeName]._getReactionCreators() 107 | }); 108 | } 109 | 110 | return new PullstateInstance(newStores as S, true, customContext); 111 | } 112 | 113 | useStores(): S { 114 | // return useContext(PullstateContext)!.stores as S; 115 | return useStores(); 116 | } 117 | 118 | useInstance(): PullstateInstance { 119 | return useInstance(); 120 | } 121 | 122 | /*actionSetup(): { 123 | action: (update: TMultiStoreAction, S>) => TMultiStoreAction, S>; 124 | act: (action: TMultiStoreAction, S>) => void; 125 | // act: (action: (update: TMultiStoreUpdateMap) => void) => void; 126 | } { 127 | const actUpdateMap = {} as TMultiStoreUpdateMap; 128 | const updatedStores = new Set(); 129 | 130 | for (const store of Object.keys(clientStores.stores)) { 131 | actUpdateMap[store as keyof S] = (updater) => { 132 | updatedStores.add(store); 133 | clientStores.stores[store].batch(updater); 134 | }; 135 | } 136 | 137 | const action: ( 138 | update: TMultiStoreAction, S> 139 | ) => TMultiStoreAction, S> = (action) => action; 140 | const act = (action: TMultiStoreAction, S>): void => { 141 | updatedStores.clear(); 142 | action(actUpdateMap); 143 | for (const store of updatedStores) { 144 | clientStores.stores[store].flushBatch(true); 145 | } 146 | }; 147 | 148 | return { 149 | action, 150 | act, 151 | }; 152 | }*/ 153 | 154 | createAsyncActionDirect( 155 | action: (args: A) => Promise, 156 | options: ICreateAsyncActionOptions = {} 157 | ): IOCreateAsyncActionOutput { 158 | return createAsyncActionDirect(action, options); 159 | // return createAsyncAction(async (args: A) => { 160 | // return successResult(await action(args)); 161 | // }, options); 162 | } 163 | 164 | createAsyncAction( 165 | action: TPullstateAsyncAction, 166 | // options: Omit, "clientStores"> = {} 167 | options: ICreateAsyncActionOptions = {} 168 | ): IOCreateAsyncActionOutput { 169 | // options.clientStores = this.originStores; 170 | if (this.options.asyncActions?.defaultCachingSeconds && !options.cacheBreakHook) { 171 | options.cacheBreakHook = (inputs) => 172 | inputs.timeCached < Date.now() - this.options.asyncActions!.defaultCachingSeconds! * 1000; 173 | } 174 | 175 | return createAsyncAction(action, options); 176 | } 177 | } 178 | 179 | type TMultiStoreUpdateMap = { 180 | [K in keyof S]: (updater: TUpdateFunction ? T : any>) => void; 181 | }; 182 | 183 | interface IPullstateSnapshot { 184 | allState: { [storeName: string]: any }; 185 | asyncResults: IPullstateAsyncResultState; 186 | asyncActionOrd: IPullstateAsyncActionOrdState; 187 | } 188 | 189 | export interface IPullstateInstanceConsumable { 190 | stores: T; 191 | 192 | hasAsyncStateToResolve(): boolean; 193 | 194 | resolveAsyncState(): Promise; 195 | 196 | getPullstateSnapshot(): IPullstateSnapshot; 197 | 198 | hydrateFromSnapshot(snapshot: IPullstateSnapshot): void; 199 | 200 | runAsyncAction( 201 | asyncAction: IOCreateAsyncActionOutput, 202 | args?: A, 203 | runOptions?: Pick, "ignoreShortCircuit" | "respectCache"> 204 | ): TPullstateAsyncRunResponse; 205 | } 206 | 207 | class PullstateInstance 208 | implements IPullstateInstanceConsumable { 209 | private _ssr: boolean = false; 210 | private _customContext: any; 211 | private readonly _stores: T = {} as T; 212 | _asyncCache: IPullstateAsyncCache = { 213 | listeners: {}, 214 | results: {}, 215 | actions: {}, 216 | actionOrd: {} 217 | }; 218 | 219 | constructor(allStores: T, ssr: boolean, customContext: any) { 220 | this._stores = allStores; 221 | this._ssr = ssr; 222 | this._customContext = customContext; 223 | /*if (!ssr) { 224 | // console.log(`Instantiating Stores`, allStores); 225 | clientStores.stores = allStores; 226 | clientStores.loaded = true; 227 | }*/ 228 | } 229 | 230 | private getAllUnresolvedAsyncActions(): Array> { 231 | return Object.keys(this._asyncCache.actions).map((key) => this._asyncCache.actions[key]()); 232 | } 233 | 234 | instantiateReactions() { 235 | for (const storeName of Object.keys(this._stores)) { 236 | this._stores[storeName]._instantiateReactions(); 237 | } 238 | } 239 | 240 | getPullstateSnapshot(): IPullstateSnapshot { 241 | const allState = {} as IPullstateSnapshot["allState"]; 242 | 243 | for (const storeName of Object.keys(this._stores)) { 244 | allState[storeName] = this._stores[storeName].getRawState(); 245 | } 246 | 247 | return { allState, asyncResults: this._asyncCache.results, asyncActionOrd: this._asyncCache.actionOrd }; 248 | } 249 | 250 | async resolveAsyncState() { 251 | const promises = this.getAllUnresolvedAsyncActions(); 252 | await Promise.all(promises); 253 | } 254 | 255 | hasAsyncStateToResolve(): boolean { 256 | return Object.keys(this._asyncCache.actions).length > 0; 257 | } 258 | 259 | get stores(): T { 260 | return this._stores; 261 | } 262 | 263 | get customContext(): any { 264 | return this._customContext; 265 | } 266 | 267 | async runAsyncAction( 268 | asyncAction: IOCreateAsyncActionOutput, 269 | args: A = {} as A, 270 | runOptions: Pick, "ignoreShortCircuit" | "respectCache"> = {} 271 | ): TPullstateAsyncRunResponse { 272 | if (this._ssr) { 273 | (runOptions as IAsyncActionRunOptions)._asyncCache = this._asyncCache; 274 | (runOptions as IAsyncActionRunOptions)._stores = this._stores; 275 | (runOptions as IAsyncActionRunOptions)._customContext = this._customContext; 276 | } 277 | 278 | return await asyncAction.run(args, runOptions); 279 | } 280 | 281 | hydrateFromSnapshot(snapshot: IPullstateSnapshot) { 282 | for (const storeName of Object.keys(this._stores)) { 283 | if (snapshot.allState.hasOwnProperty(storeName)) { 284 | this._stores[storeName]._updateStateWithoutReaction(snapshot.allState[storeName]); 285 | } else { 286 | console.warn(`${storeName} didn't hydrate any state (data was non-existent on hydration object)`); 287 | } 288 | } 289 | 290 | clientAsyncCache.results = snapshot.asyncResults || {}; 291 | clientAsyncCache.actionOrd = snapshot.asyncActionOrd || {}; 292 | } 293 | } 294 | 295 | export function createPullstateCore( 296 | allStores: T = {} as T, 297 | options: IPullstateSingletonOptions = {} 298 | ) { 299 | return new PullstateSingleton(allStores, options); 300 | } 301 | 302 | export function useStores() { 303 | return useContext(PullstateContext)!.stores as T; 304 | } 305 | 306 | export function useInstance(): PullstateInstance { 307 | return useContext(PullstateContext)! as PullstateInstance; 308 | } 309 | -------------------------------------------------------------------------------- /src/async-types.ts: -------------------------------------------------------------------------------- 1 | import { IPullstateAllStores } from "./PullstateCore"; 2 | import { TUpdateFunction } from "./Store"; 3 | 4 | type TPullstateAsyncUpdateListener = () => void; 5 | 6 | // [ started, finished, result, updating, timeCached ] 7 | export type TPullstateAsyncWatchResponse = [ 8 | boolean, 9 | boolean, 10 | TAsyncActionResult, 11 | boolean, 12 | number 13 | ]; 14 | 15 | // export type TPullstateAsync 16 | 17 | // [ started, finished, result, updating, postActionResult ] 18 | // export type TPullstateAsyncResponseCacheFull = [ 19 | // boolean, 20 | // boolean, 21 | // TAsyncActionResult, 22 | // boolean, 23 | // TAsyncActionResult | true | null 24 | // ]; 25 | 26 | // [finished, result, updating] 27 | export type TPullstateAsyncBeckonResponse = [ 28 | boolean, 29 | TAsyncActionResult, 30 | boolean 31 | ]; 32 | // [result] 33 | export type TPullstateAsyncRunResponse = Promise>; 34 | 35 | export interface IPullstateAsyncResultState { 36 | [key: string]: TPullstateAsyncWatchResponse; 37 | } 38 | 39 | export interface IPullstateAsyncActionOrdState { 40 | [key: string]: number; 41 | } 42 | 43 | export enum EAsyncEndTags { 44 | THREW_ERROR = "THREW_ERROR", 45 | RETURNED_ERROR = "RETURNED_ERROR", 46 | UNFINISHED = "UNFINISHED", 47 | DORMANT = "DORMANT", 48 | } 49 | 50 | interface IAsyncActionResultBase { 51 | message: string; 52 | tags: (EAsyncEndTags | T)[]; 53 | } 54 | 55 | export interface IAsyncActionResultPositive extends IAsyncActionResultBase { 56 | error: false; 57 | payload: R; 58 | errorPayload: null; 59 | } 60 | 61 | export interface IAsyncActionResultNegative extends IAsyncActionResultBase { 62 | error: true; 63 | errorPayload: N; 64 | payload: null; 65 | } 66 | 67 | export type TAsyncActionResult = 68 | IAsyncActionResultPositive 69 | | IAsyncActionResultNegative; 70 | 71 | // Order of new hook functions: 72 | 73 | // shortCircuitHook = ({ args, stores }) => cachable response | false - happens only on uncached action 74 | // cacheBreakHook = ({ args, stores, result }) => true | false - happens only on cached action 75 | // postActionHook = ({ args, result, stores }) => void | new result - happens on all actions, after the async / short circuit has resolved 76 | // ----> postActionHook potentially needs a mechanism which allows it to run only once per new key change (another layer caching of some sorts expiring on key change) 77 | 78 | export type TPullstateAsyncShortCircuitHook = (inputs: { 79 | args: A; 80 | stores: S; 81 | }) => TAsyncActionResult | false; 82 | 83 | export type TPullstateAsyncCacheBreakHook = (inputs: { 84 | args: A; 85 | result: TAsyncActionResult; 86 | stores: S; 87 | timeCached: number; 88 | }) => boolean; 89 | 90 | export enum EPostActionContext { 91 | WATCH_HIT_CACHE = "WATCH_HIT_CACHE", 92 | BECKON_HIT_CACHE = "BECKON_HIT_CACHE", 93 | RUN_HIT_CACHE = "RUN_HIT_CACHE", 94 | READ_HIT_CACHE = "READ_HIT_CACHE", 95 | READ_RUN = "READ_RUN", 96 | SHORT_CIRCUIT = "SHORT_CIRCUIT", 97 | DIRECT_RUN = "DIRECT_RUN", 98 | BECKON_RUN = "BECKON_RUN", 99 | CACHE_UPDATE = "CACHE_UPDATE", 100 | } 101 | 102 | export type TPullstateAsyncPostActionHook = (inputs: { 103 | args: A; 104 | result: TAsyncActionResult; 105 | stores: S; 106 | context: EPostActionContext; 107 | }) => void; 108 | 109 | export interface IAsyncActionReadOptions { 110 | postActionEnabled?: boolean; 111 | cacheBreakEnabled?: boolean; 112 | key?: string; 113 | cacheBreak?: boolean | number | TPullstateAsyncCacheBreakHook 114 | } 115 | 116 | export interface IAsyncActionBeckonOptions extends IAsyncActionReadOptions { 117 | ssr?: boolean; 118 | holdPrevious?: boolean; 119 | dormant?: boolean; 120 | } 121 | 122 | export interface IAsyncActionWatchOptions extends IAsyncActionBeckonOptions { 123 | initiate?: boolean; 124 | } 125 | 126 | export interface IAsyncActionUseOptions extends IAsyncActionWatchOptions { 127 | onSuccess?: (result: R, args: A) => void; 128 | } 129 | 130 | export interface IAsyncActionUseDeferOptions extends Omit, "key"> { 131 | key?: string; 132 | holdPrevious?: boolean; 133 | onSuccess?: (result: R, args: A) => void; 134 | clearOnSuccess?: boolean; 135 | } 136 | 137 | export interface IAsyncActionRunOptions { 138 | treatAsUpdate?: boolean; 139 | ignoreShortCircuit?: boolean; 140 | respectCache?: boolean; 141 | key?: string; 142 | cacheBreak?: boolean | number | TPullstateAsyncCacheBreakHook 143 | _asyncCache?: IPullstateAsyncCache; 144 | _stores?: S; 145 | _customContext?: any; 146 | } 147 | 148 | export interface IAsyncActionGetCachedOptions { 149 | checkCacheBreak?: boolean; 150 | cacheBreak?: boolean | number | TPullstateAsyncCacheBreakHook; 151 | key?: string; 152 | } 153 | 154 | export interface IGetCachedResponse { 155 | started: boolean; 156 | finished: boolean; 157 | result: TAsyncActionResult; 158 | updating: boolean; 159 | existed: boolean; 160 | cacheBreakable: boolean; 161 | timeCached: number; 162 | } 163 | 164 | export interface IAsyncClearCacheOptions { 165 | notify?: boolean; 166 | } 167 | 168 | export interface IAsyncActionSetOrClearCachedValueOptions extends IAsyncClearCacheOptions { 169 | key?: string; 170 | } 171 | 172 | export interface IAsyncActionUpdateCachedOptions extends IAsyncActionSetOrClearCachedValueOptions { 173 | resetTimeCached?: boolean; 174 | runPostActionHook?: boolean; 175 | } 176 | 177 | export type TAsyncActionUse = ( 178 | args?: A, 179 | options?: IAsyncActionUseOptions 180 | ) => TUseResponse; 181 | 182 | export type TAsyncActionUseDefer = ( 183 | options?: IAsyncActionUseDeferOptions 184 | ) => TUseDeferResponse; 185 | 186 | export type TAsyncActionBeckon = ( 187 | args?: A, 188 | options?: IAsyncActionBeckonOptions 189 | ) => TPullstateAsyncBeckonResponse; 190 | 191 | export type TAsyncActionWatch = ( 192 | args?: A, 193 | options?: IAsyncActionWatchOptions 194 | ) => TPullstateAsyncWatchResponse; 195 | 196 | export type TAsyncActionRun = ( 197 | args?: A, 198 | options?: IAsyncActionRunOptions 199 | ) => TPullstateAsyncRunResponse; 200 | 201 | export type TAsyncActionClearCache = (args?: A, options?: IAsyncActionSetOrClearCachedValueOptions) => void; 202 | 203 | export type TAsyncActionClearAllCache = (options?: IAsyncClearCacheOptions) => void; 204 | 205 | export type TAsyncActionClearAllUnwatchedCache = (options?: IAsyncClearCacheOptions) => void; 206 | 207 | export type TAsyncActionGetCached = ( 208 | args?: A, 209 | options?: IAsyncActionGetCachedOptions 210 | ) => IGetCachedResponse; 211 | 212 | export type TAsyncActionSetCached = ( 213 | args: A, 214 | result: TAsyncActionResult, 215 | options?: IAsyncActionSetOrClearCachedValueOptions 216 | ) => void; 217 | 218 | export type TAsyncActionSetCachedPayload = (args: A, payload: R, options?: IAsyncActionSetOrClearCachedValueOptions) => void; 219 | 220 | export type TAsyncActionUpdateCached = ( 221 | args: A, 222 | updater: TUpdateFunction, 223 | options?: IAsyncActionUpdateCachedOptions 224 | ) => void; 225 | export type TAsyncActionRead = (args?: A, options?: IAsyncActionReadOptions) => R; 226 | 227 | export type TAsyncActionDelayedRun = ( 228 | args: A, 229 | options: IAsyncActionRunOptions & { delay: number; clearOldRun?: boolean; immediateIfCached?: boolean } 230 | ) => () => void; 231 | 232 | export interface IOCreateAsyncActionOutput { 233 | use: TAsyncActionUse; 234 | useDefer: TAsyncActionUseDefer; 235 | read: TAsyncActionRead; 236 | useBeckon: TAsyncActionBeckon; 237 | useWatch: TAsyncActionWatch; 238 | run: TAsyncActionRun; 239 | delayedRun: TAsyncActionDelayedRun; 240 | getCached: TAsyncActionGetCached; 241 | setCached: TAsyncActionSetCached; 242 | setCachedPayload: TAsyncActionSetCachedPayload; 243 | updateCached: TAsyncActionUpdateCached; 244 | clearCache: TAsyncActionClearCache; 245 | clearAllCache: TAsyncActionClearAllCache; 246 | clearAllUnwatchedCache: TAsyncActionClearAllUnwatchedCache; 247 | } 248 | 249 | export interface IPullstateAsyncCache { 250 | results: IPullstateAsyncResultState; 251 | listeners: { 252 | [key: string]: { 253 | [watchId: string]: TPullstateAsyncUpdateListener; 254 | }; 255 | }; 256 | actions: { 257 | [key: string]: () => Promise>; 258 | }; 259 | actionOrd: IPullstateAsyncActionOrdState; 260 | } 261 | 262 | export type TPullstateAsyncAction = ( 263 | args: A, 264 | stores: S, 265 | customContext: any 266 | ) => Promise>; 267 | 268 | export interface ICreateAsyncActionOptions { 269 | forceContext?: boolean; 270 | // clientStores?: S; 271 | shortCircuitHook?: TPullstateAsyncShortCircuitHook; 272 | cacheBreakHook?: TPullstateAsyncCacheBreakHook; 273 | postActionHook?: TPullstateAsyncPostActionHook; 274 | subsetKey?: (args: A) => any; 275 | actionId?: string | number; 276 | } 277 | 278 | // action.use() types 279 | 280 | export interface IUseDebouncedExecutionOptions { 281 | validInput?: (args: A) => boolean; 282 | equality?: ((argsPrev: A, argsNew: A) => boolean) | any; 283 | executeOptions?: Omit, "key" | "cacheBreak">; 284 | watchLastValid?: boolean; 285 | } 286 | 287 | export type TRunWithPayload = (func: (payload: R) => any) => any; 288 | 289 | export interface IBaseObjResponseUse { 290 | execute: (runOptions?: IAsyncActionRunOptions) => TPullstateAsyncRunResponse; 291 | } 292 | 293 | export interface IBaseObjResponseUseDefer { 294 | execute: (args?: A, runOptions?: Omit, "key" | "cacheBreak">) => TPullstateAsyncRunResponse; 295 | hasCached: (args?: A, options?: { successOnly?: boolean } & Omit, "key">) => boolean; 296 | unwatchExecuted: () => void; 297 | useDebouncedExecution: (args: A, delay: number, options?: IUseDebouncedExecutionOptions) => void; 298 | args: A; 299 | key: string; 300 | } 301 | 302 | export interface IBaseObjResponse { 303 | isLoading: boolean; 304 | isFinished: boolean; 305 | isUpdating: boolean; 306 | isStarted: boolean; 307 | // isSuccess: boolean; 308 | // isFailure: boolean; 309 | clearCached: () => void; 310 | updateCached: (updater: TUpdateFunction, options?: IAsyncActionUpdateCachedOptions) => void; 311 | setCached: (result: TAsyncActionResult, options?: IAsyncActionSetOrClearCachedValueOptions) => void; 312 | setCachedPayload: (payload: R, options?: IAsyncActionSetOrClearCachedValueOptions) => void; 313 | endTags: (T | EAsyncEndTags)[]; 314 | renderPayload: TRunWithPayload; 315 | message: string; 316 | raw: TPullstateAsyncWatchResponse; 317 | } 318 | 319 | export interface IBaseObjSuccessResponse extends IBaseObjResponse { 320 | payload: R; 321 | errorPayload: null; 322 | error: false; 323 | isSuccess: true; 324 | isFailure: false; 325 | } 326 | 327 | export interface IBaseObjErrorResponse extends IBaseObjResponse { 328 | payload: null; 329 | errorPayload: N; 330 | error: true; 331 | isFailure: true; 332 | isSuccess: false; 333 | } 334 | 335 | export type TUseResponse = 336 | (IBaseObjSuccessResponse 337 | | IBaseObjErrorResponse) & IBaseObjResponseUse; 338 | 339 | export type TUseDeferResponse = 340 | (IBaseObjSuccessResponse 341 | | IBaseObjErrorResponse) & IBaseObjResponseUseDefer; 342 | -------------------------------------------------------------------------------- /src/batch.ts: -------------------------------------------------------------------------------- 1 | import { globalClientState } from "./globalClientState"; 2 | 3 | interface IBatchState { 4 | uiBatchFunction: ((updates: () => void) => void); 5 | } 6 | 7 | const batchState: Partial = {}; 8 | 9 | export function setupBatch({ uiBatchFunction }: IBatchState) { 10 | batchState.uiBatchFunction = uiBatchFunction; 11 | } 12 | 13 | export function batch(runUpdates: () => void) { 14 | if (globalClientState.batching) { 15 | throw new Error("Pullstate: Can't enact two batch() update functions at the same time-\n" + 16 | "make sure you are not running a batch() inside of a batch() by mistake."); 17 | } 18 | 19 | globalClientState.batching = true; 20 | 21 | try { 22 | runUpdates(); 23 | } finally { 24 | if (batchState.uiBatchFunction) { 25 | batchState.uiBatchFunction(() => { 26 | Object.values(globalClientState.flushStores).forEach(store => store.flushBatch(true)); 27 | }); 28 | } else { 29 | Object.values(globalClientState.flushStores).forEach(store => store.flushBatch(true)); 30 | } 31 | globalClientState.flushStores = {}; 32 | globalClientState.batching = false; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fastDeepEqual.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fast-deep-equal/es6" { 2 | const equal: (a: any, b: any) => boolean; 3 | export = equal; 4 | } 5 | -------------------------------------------------------------------------------- /src/globalClientState.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./Store"; 2 | 3 | export const globalClientState: { 4 | storeOrdinal: number, 5 | batching: boolean; 6 | flushStores: { 7 | [storeName: number]: Store; 8 | }; 9 | } = { 10 | storeOrdinal: 0, 11 | batching: false, 12 | flushStores: {} 13 | }; 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useStoreState } from "./useStoreState"; 2 | import { Store, TStoreAction, TUpdateFunction, update } from "./Store"; 3 | import { InjectStoreState } from "./InjectStoreState"; 4 | import type { PullstateSingleton } from "./PullstateCore"; 5 | import { 6 | createPullstateCore, 7 | IPullstateAllStores, 8 | IPullstateInstanceConsumable, 9 | PullstateContext, 10 | PullstateProvider, 11 | TMultiStoreAction, 12 | useInstance, 13 | useStores 14 | } from "./PullstateCore"; 15 | import { createAsyncAction, createAsyncActionDirect, errorResult, successResult } from "./async"; 16 | import { EAsyncActionInjectType, InjectAsyncAction, TInjectAsyncActionProps } from "./InjectAsyncAction"; 17 | import { TUseResponse } from "./async-types"; 18 | import { registerInDevtools } from "./reduxDevtools"; 19 | import { useLocalStore } from "./useLocalStore"; 20 | import { batch, setupBatch } from "./batch"; 21 | 22 | export * from "./async-types"; 23 | 24 | export { 25 | useStoreState, 26 | useLocalStore, 27 | update, 28 | Store, 29 | InjectStoreState, 30 | PullstateProvider, 31 | useStores, 32 | useInstance, 33 | createPullstateCore, 34 | createAsyncAction, 35 | createAsyncActionDirect, 36 | successResult, 37 | errorResult, 38 | // EAsyncEndTags, 39 | IPullstateInstanceConsumable, 40 | IPullstateAllStores, 41 | InjectAsyncAction, 42 | EAsyncActionInjectType, 43 | TInjectAsyncActionProps, 44 | // TPullstateAsyncAction, 45 | // TAsyncActionResult, 46 | TUpdateFunction, 47 | TStoreAction, 48 | TMultiStoreAction, 49 | PullstateContext, 50 | TUseResponse, 51 | registerInDevtools, 52 | batch, 53 | setupBatch 54 | }; 55 | 56 | export type { 57 | PullstateSingleton 58 | }; 59 | -------------------------------------------------------------------------------- /src/reduxDevtools.ts: -------------------------------------------------------------------------------- 1 | import { IPullstateAllStores } from "./PullstateCore"; 2 | 3 | interface IORegisterInDevtoolsOptions { 4 | namespace?: string; 5 | } 6 | 7 | export function registerInDevtools(stores: IPullstateAllStores, { namespace = "" }: IORegisterInDevtoolsOptions = {}) { 8 | const devToolsExtension = typeof window !== "undefined" ? (window as any)?.__REDUX_DEVTOOLS_EXTENSION__ : undefined; 9 | 10 | if (devToolsExtension) { 11 | for (const key of Object.keys(stores)) { 12 | const store = stores[key]; 13 | 14 | const devTools = devToolsExtension.connect({ name: `${namespace}${key}` }); 15 | devTools.init(store.getRawState()); 16 | let ignoreNext = false; 17 | /*store.subscribe( 18 | (state) => { 19 | if (ignoreNext) { 20 | ignoreNext = false; 21 | return; 22 | } 23 | devTools.send("Change", state); 24 | }, 25 | () => {} 26 | );*/ 27 | store.subscribe( 28 | (s) => s, 29 | (watched) => { 30 | if (ignoreNext) { 31 | ignoreNext = false; 32 | return; 33 | } 34 | devTools.send("Change", watched); 35 | } 36 | ); 37 | 38 | devTools.subscribe((message: { type: string; state: any }) => { 39 | if (message.type === "DISPATCH" && message.state) { 40 | ignoreNext = true; 41 | const parsed = JSON.parse(message.state); 42 | store.replace(parsed); 43 | } 44 | }); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/useLocalStore.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./Store"; 2 | import { useRef } from "react"; 3 | import isEqual from "fast-deep-equal/es6"; 4 | 5 | function useLocalStore(initialState: (() => S) | S, deps?: ReadonlyArray): Store { 6 | const storeRef = useRef>(); 7 | 8 | if (storeRef.current == null) { 9 | storeRef.current = new Store(initialState); 10 | } 11 | 12 | if (deps !== undefined) { 13 | const prevDeps = useRef>(deps); 14 | if (!isEqual(deps, prevDeps)) { 15 | storeRef.current = new Store(initialState); 16 | } 17 | } 18 | 19 | return storeRef.current; 20 | } 21 | 22 | export { useLocalStore }; 23 | -------------------------------------------------------------------------------- /src/useStoreState.ts: -------------------------------------------------------------------------------- 1 | import isEqual from "fast-deep-equal/es6"; 2 | // S = State 3 | // SS = Sub-state 4 | import { useEffect, useRef, useState } from "react"; 5 | import { Store } from "./Store"; 6 | 7 | // const isEqual = require("fast-deep-equal/es6"); 8 | 9 | export interface IUpdateRef { 10 | shouldUpdate: boolean; 11 | onStoreUpdate: (() => void) | null; 12 | getSubState?: (state: any) => any; 13 | currentSubState: any; 14 | setInitial: boolean; 15 | } 16 | 17 | export interface IUpdateRefNew { 18 | state: any; 19 | initialized: boolean; 20 | } 21 | 22 | const onServer = typeof window === "undefined"; 23 | 24 | function useStoreState(store: Store): S; 25 | function useStoreState( 26 | store: Store, 27 | getSubState: (state: S) => SS, 28 | deps?: ReadonlyArray 29 | ): SS; 30 | function useStoreState(store: Store, getSubState?: (state: any) => any, deps?: ReadonlyArray): any { 31 | const updateRef = useRef({ state: undefined, initialized: false }); 32 | 33 | if (!updateRef.current.initialized) { 34 | updateRef.current.state = getSubState ? getSubState(store.getRawState()) : store.getRawState(); 35 | updateRef.current.initialized = true; 36 | } 37 | 38 | // useState with only a simple value to prevent double equality checks for the state 39 | const [, setUpdateTrigger] = useState(0); 40 | 41 | // const [current, setCurrent] = useState(() => { 42 | // return getSubState ? getSubState(store.getRawState()) : store.getRawState(); 43 | // }); 44 | // updateRef.current.state = current; 45 | 46 | useEffect(() => { 47 | const effectState = { shouldUpdate: true }; 48 | 49 | function update() { 50 | if (effectState.shouldUpdate) { 51 | const nextSubState = getSubState 52 | ? getSubState(store.getRawState()) 53 | : store.getRawState(); 54 | 55 | if (!isEqual(updateRef.current.state, nextSubState)) { 56 | // final check again before actually running state update (might prevent no-op errors with React) 57 | if (effectState.shouldUpdate) { 58 | updateRef.current.state = nextSubState; 59 | setUpdateTrigger((val) => val + 1); 60 | } 61 | } 62 | } 63 | } 64 | 65 | store._addUpdateListener(update); 66 | // This ensures we get the latest updated value, even if it has changed 67 | // between the initial rendering and the effect 68 | update(); 69 | 70 | return () => { 71 | effectState.shouldUpdate = false; 72 | store._removeUpdateListener(update); 73 | }; 74 | }, deps ?? []); 75 | 76 | if (deps !== undefined) { 77 | const prevDeps = useRef>(deps); 78 | if (!isEqual(deps, prevDeps)) { 79 | updateRef.current.state = getSubState!(store.getRawState()); 80 | } 81 | } 82 | 83 | return updateRef.current.state; 84 | } 85 | 86 | /* 87 | function useStoreState(store: Store, getSubState?: (state: any) => any, deps?: ReadonlyArray): any { 88 | const updateRef = useRef({ 89 | shouldUpdate: true, 90 | onStoreUpdate: null, 91 | getSubState, 92 | currentSubState: null, 93 | setInitial: false, 94 | }); 95 | 96 | const [, setUpdateTrigger] = useState(0); 97 | 98 | if (!updateRef.current.setInitial) { 99 | updateRef.current.currentSubState = updateRef.current.getSubState 100 | ? updateRef.current.getSubState(store.getRawState()) 101 | : store.getRawState(); 102 | updateRef.current.setInitial = true; 103 | } 104 | 105 | if (updateRef.current.onStoreUpdate === null) { 106 | updateRef.current.onStoreUpdate = function onStoreUpdate() { 107 | const nextSubState = updateRef.current.getSubState 108 | ? updateRef.current.getSubState(store.getRawState()) 109 | : store.getRawState(); 110 | if (updateRef.current.shouldUpdate && !isEqual(updateRef.current.currentSubState, nextSubState)) { 111 | // final check again before actually running state update (might prevent no-op errors with React) 112 | if (updateRef.current.shouldUpdate) { 113 | updateRef.current.currentSubState = nextSubState; 114 | setUpdateTrigger((val) => val + 1); 115 | } 116 | } 117 | }; 118 | 119 | if (!onServer) { 120 | store._addUpdateListener(updateRef.current.onStoreUpdate!); 121 | } 122 | } 123 | 124 | useEffect(() => { 125 | updateRef.current.shouldUpdate = true; 126 | 127 | return () => { 128 | updateRef.current.shouldUpdate = false; 129 | store._removeUpdateListener(updateRef.current.onStoreUpdate!); 130 | }; 131 | }, []); 132 | 133 | if (deps !== undefined) { 134 | const prevDeps = useRef>(deps); 135 | if (!isEqual(deps, prevDeps)) { 136 | updateRef.current.getSubState = getSubState; 137 | updateRef.current.currentSubState = getSubState!(store.getRawState()); 138 | } 139 | } 140 | 141 | console.log(updateRef.current); 142 | 143 | return updateRef.current.currentSubState; 144 | } 145 | */ 146 | 147 | export { useStoreState }; 148 | -------------------------------------------------------------------------------- /src/useStoreStateOpt-types.ts: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | 3 | type ExtractObj = K extends keyof S ? S[K] : never 4 | 5 | export type ObjectPath = 6 | T extends readonly [infer T0, ...infer TR] 7 | ? TR extends [] 8 | ? ExtractObj extends never 9 | ? readonly [] 10 | : readonly [T0] 11 | : ExtractObj extends object 12 | ? readonly [T0, ...ObjectPath, TR>] 13 | : ExtractObj extends never 14 | ? readonly [] 15 | : readonly [T0] 16 | : readonly [] 17 | 18 | /* 19 | export interface DeepKeyOfArray extends Array { 20 | ["0"]: keyof O; 21 | ["1"]?: this extends { 22 | ["0"]: infer K0 23 | } ? 24 | K0 extends keyof O ? 25 | O[K0] extends Array ? 26 | number 27 | : 28 | keyof O[K0] 29 | : 30 | never 31 | : 32 | never; 33 | [rest: string]: any; 34 | } 35 | 36 | export type TAllPathsParameter = 37 | | [DeepKeyOfArray] 38 | | [DeepKeyOfArray, DeepKeyOfArray] 39 | | [DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray] 40 | | [DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray] 41 | | [DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray] 42 | | [DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray, DeepKeyOfArray] 43 | | [ 44 | DeepKeyOfArray, 45 | DeepKeyOfArray, 46 | DeepKeyOfArray, 47 | DeepKeyOfArray, 48 | DeepKeyOfArray, 49 | DeepKeyOfArray, 50 | DeepKeyOfArray 51 | ] 52 | | [ 53 | DeepKeyOfArray, 54 | DeepKeyOfArray, 55 | DeepKeyOfArray, 56 | DeepKeyOfArray, 57 | DeepKeyOfArray, 58 | DeepKeyOfArray, 59 | DeepKeyOfArray, 60 | DeepKeyOfArray 61 | ] 62 | | [ 63 | DeepKeyOfArray, 64 | DeepKeyOfArray, 65 | DeepKeyOfArray, 66 | DeepKeyOfArray, 67 | DeepKeyOfArray, 68 | DeepKeyOfArray, 69 | DeepKeyOfArray, 70 | DeepKeyOfArray, 71 | DeepKeyOfArray 72 | ] 73 | | [ 74 | DeepKeyOfArray, 75 | DeepKeyOfArray, 76 | DeepKeyOfArray, 77 | DeepKeyOfArray, 78 | DeepKeyOfArray, 79 | DeepKeyOfArray, 80 | DeepKeyOfArray, 81 | DeepKeyOfArray, 82 | DeepKeyOfArray, 83 | DeepKeyOfArray 84 | ]; 85 | 86 | export type ArrayHasIndex = { [K in MinLength]: any }; 87 | 88 | export type DeepTypeOfArray | undefined> = L extends ArrayHasIndex< 89 | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" 90 | > 91 | ? any 92 | : L extends ArrayHasIndex<"0" | "1" | "2" | "3" | "4" | "5" | "6"> 93 | ? T[L["0"]][L["1"]][L["2"]][L["3"]][L["4"]][L["5"]][L["6"]] 94 | : L extends ArrayHasIndex<"0" | "1" | "2" | "3" | "4" | "5"> 95 | ? T[L["0"]][L["1"]][L["2"]][L["3"]][L["4"]][L["5"]] 96 | : L extends ArrayHasIndex<"0" | "1" | "2" | "3" | "4"> 97 | ? T[L["0"]][L["1"]][L["2"]][L["3"]][L["4"]] 98 | : L extends ArrayHasIndex<"0" | "1" | "2" | "3"> 99 | ? T[L["0"]][L["1"]][L["2"]][L["3"]] 100 | : L extends ArrayHasIndex<"0" | "1" | "2"> 101 | ? T[L["0"]][L["1"]][L["2"]] 102 | : L extends ArrayHasIndex<"0" | "1"> 103 | ? T[L["0"]][L["1"]] 104 | : L extends ArrayHasIndex<"0"> 105 | ? T[L["0"]] 106 | : never; 107 | */ 108 | -------------------------------------------------------------------------------- /src/useStoreStateOpt.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./Store"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { IUpdateRef } from "./useStoreState"; 4 | import { ObjectPath } from "./useStoreStateOpt-types"; 5 | 6 | let updateListenerOrd = 0; 7 | 8 | function fastGet(obj: S, path: any[]): any { 9 | return path.reduce((cur: any = obj, key: string | number) => { 10 | return cur[key]; 11 | }, undefined); 12 | } 13 | 14 | function getSubStateFromPaths< 15 | T extends readonly unknown[], 16 | S extends object = object, 17 | P extends ObjectPath = T extends ObjectPath ? T : never 18 | >(store: Store, paths: P): any[] { 19 | const state: any = store.getRawState(); 20 | 21 | const resp: any[] = []; 22 | 23 | for (const path of paths) { 24 | resp.push(fastGet(state, path)); 25 | } 26 | 27 | return resp; 28 | } 29 | 30 | function useStoreStateOpt< 31 | T extends readonly unknown[], 32 | S extends object = object, 33 | P extends ObjectPath = T extends ObjectPath ? T : never 34 | >(store: Store, paths: any) { 35 | const [subState, setSubState] = useState(() => getSubStateFromPaths(store, paths)); 36 | 37 | const updateRef = useRef>({ 38 | shouldUpdate: true, 39 | onStoreUpdate: null, 40 | currentSubState: null, 41 | ordKey: `_${updateListenerOrd++}`, 42 | }); 43 | 44 | updateRef.current.currentSubState = subState; 45 | 46 | if (updateRef.current.onStoreUpdate === null) { 47 | updateRef.current.onStoreUpdate = function onStoreUpdateOpt() { 48 | // console.log(`Running onStoreUpdate from useStoreStateOpt ${updateRef.current.ordKey}`); 49 | if (updateRef.current.shouldUpdate) { 50 | setSubState(getSubStateFromPaths(store, paths)); 51 | } 52 | }; 53 | // store._addUpdateListenerOpt(updateRef.current.onStoreUpdate, updateRef.current.ordKey!, paths); 54 | } 55 | 56 | useEffect( 57 | () => () => { 58 | // console.log(`removing opt listener ord:"${updateRef.current.ordKey}"`); 59 | updateRef.current.shouldUpdate = false; 60 | store._removeUpdateListenerOpt(updateRef.current.ordKey!); 61 | }, 62 | [] 63 | ); 64 | 65 | return subState; 66 | } 67 | 68 | export { useStoreStateOpt }; 69 | -------------------------------------------------------------------------------- /test/benchmark/BenchmarkUtils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | const randomNumbers = [100, 200, 300, 400, 500]; 4 | const randomQueryString = [ 5 | "thasd;kljaasdasd", 6 | "123978120378sadsda", 7 | "asdhixcluyisadsd", 8 | "qweu07sdvohjjksd", 9 | "1320918khjlabnm", 10 | ]; 11 | const randomBools = [true, false, true, false, false]; 12 | const randomAny = [null, undefined, 123, false, "asdasduqoweuh"]; 13 | 14 | export interface IRandomArgObject { 15 | limit: number; 16 | queryString: string; 17 | isItGood: boolean; 18 | anything: any; 19 | } 20 | 21 | export function createRandomArgs(amount: number): IRandomArgObject[] { 22 | const args: IRandomArgObject[] = []; 23 | 24 | for (let i = 0; i <= amount; i += 1) { 25 | args.push({ 26 | limit: _.sample(randomNumbers), 27 | queryString: _.sample(randomQueryString), 28 | isItGood: _.sample(randomBools), 29 | anything: _.sample(randomAny), 30 | }); 31 | } 32 | 33 | return args; 34 | } 35 | -------------------------------------------------------------------------------- /test/benchmark/all.ts: -------------------------------------------------------------------------------- 1 | import "./benchmark-async-argument.ts" 2 | import "./benchmark-destructuring" 3 | import "./benchmark-immer-without-stores" 4 | import "./benchmark-immer-with-stores" -------------------------------------------------------------------------------- /test/benchmark/benchmark-all-immer.ts: -------------------------------------------------------------------------------- 1 | import "./benchmark-immer-without-stores"; 2 | import "./benchmark-immer-with-stores"; -------------------------------------------------------------------------------- /test/benchmark/benchmark-async-argument.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { createRandomArgs } from "./BenchmarkUtils"; 3 | import { keyFromObjectImplementations } from "./keyFromObjectImplementations"; 4 | 5 | const testArgs = { 6 | limit: 300, 7 | deeper: { 8 | something: null, 9 | cor: "false", 10 | far: true, 11 | egg: [false, null, undefined, 312, "eggs"], 12 | }, 13 | queryString: "bring-it-on-three", 14 | isItGood: true, 15 | anything: false, 16 | }; 17 | 18 | console.log(`\nkeyFromObjectOld()`); 19 | console.log(keyFromObjectOld(testArgs)); 20 | console.log(`\npullstateCustomKeyCreator()`); 21 | console.log(pullstateCustomKeyCreator(testArgs)); 22 | console.log(`\nJSON.stringify()`); 23 | console.log(JSON.stringify(testArgs)); 24 | console.log(`\nJSON stringify replace quotes:`); 25 | console.log(jsonStringifyReplaceQuotes(testArgs)); 26 | console.log(`\nNew key to object (concat):`); 27 | console.log(keyFromObjectImplementations.keyFromObjectConcat(testArgs)); 28 | console.log(`\nNew key to object (template strings):`); 29 | console.log(keyFromObjectImplementations.keyFromObjectTemplate(testArgs)); 30 | console.log(`\nNew key to object (concat newer):`); 31 | console.log(keyFromObjectImplementations.keyFromObjectConcatNew(testArgs)); 32 | 33 | function keyFromObjectOld(jsonObject: any): string { 34 | if (typeof jsonObject !== "object" || Array.isArray(jsonObject) || jsonObject === null || jsonObject === undefined) { 35 | if (typeof jsonObject === "string") { 36 | return `${jsonObject}`; 37 | } 38 | return JSON.stringify(jsonObject); 39 | } 40 | 41 | let props = Object.keys(jsonObject) 42 | .sort() 43 | .map(key => `${key}:${keyFromObjectOld(jsonObject[key])}`) 44 | .join(","); 45 | return `${props}`; 46 | } 47 | 48 | function pullstateCustomKeyCreator(json: any): string { 49 | if (json == null) { 50 | return `${json}`; 51 | } 52 | 53 | let prefix = ""; 54 | 55 | for (const key of Object.keys(json).sort()) { 56 | prefix += key; 57 | 58 | if (typeof json[key] == null) { 59 | prefix += JSON.stringify(json[key]); 60 | } else if (typeof json[key] === "string" ) { 61 | prefix += `~${json[key]}~`; 62 | } else if (typeof json[key] === "boolean" || typeof json[key] === "number") { 63 | prefix += json[key]; 64 | } else { 65 | prefix += pullstateCustomKeyCreator(json[key]); 66 | } 67 | } 68 | 69 | return prefix; 70 | } 71 | 72 | function runKeyCreator(func: (json: any) => string, args: any[]): [number, string[]] { 73 | const timeStart = Date.now(); 74 | const keys: string[] = []; 75 | 76 | for (const arg of args) { 77 | keys.push(func(arg)); 78 | } 79 | 80 | return [Date.now() - timeStart, keys]; 81 | } 82 | 83 | 84 | const args = createRandomArgs(200); 85 | 86 | function jsonStringifyReplaceQuotes(obj: any) { 87 | return JSON.stringify(obj).replace("\"", "-"); 88 | } 89 | 90 | console.log("\n"); 91 | 92 | const suiteName = "Async Arguments to Key String"; 93 | 94 | new Benchmark.Suite(suiteName) 95 | .add(`JSON.stringify()`, function() { 96 | runKeyCreator(JSON.stringify, args); 97 | }) 98 | // .add(`JSON.stringify()-replace-quotes`, function() { 99 | // runKeyCreator(jsonStringifyReplaceQuotes, args); 100 | // }) 101 | // .add(`keyFromObjectOld()`, function() { 102 | // runKeyCreator(keyFromObjectOld, args); 103 | // }) 104 | .add(`pullstateCustomKeyCreator()`, function() { 105 | runKeyCreator(pullstateCustomKeyCreator, args); 106 | }) 107 | .add(`keyFromObjectConcat()`, function() { 108 | runKeyCreator(keyFromObjectImplementations.keyFromObjectConcat, args); 109 | }) 110 | .add(`keyFromObjectTemplate()`, function() { 111 | runKeyCreator(keyFromObjectImplementations.keyFromObjectTemplate, args); 112 | }) 113 | .add(`keyFromObjectConcatNew()`, function() { 114 | runKeyCreator(keyFromObjectImplementations.keyFromObjectConcatNew, args); 115 | }) 116 | .on("cycle", function(event) { 117 | // console.log(event); 118 | console.log(String(event.target)); 119 | }) 120 | .on("complete", function() { 121 | console.log(`\n${suiteName} - Fastest is ` + this.filter("fastest").map("name")); 122 | }) 123 | .run(); 124 | -------------------------------------------------------------------------------- /test/benchmark/benchmark-destructuring.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { createRandomArgs, IRandomArgObject } from "./BenchmarkUtils"; 3 | 4 | const argsObjects = createRandomArgs(1000); 5 | const argsObjectsTwo = createRandomArgs(1000); 6 | 7 | function returnArrayFromArgs(args: IRandomArgObject): [number, string, boolean, any] { 8 | return [args.limit, args.queryString, args.isItGood, args.anything]; 9 | } 10 | 11 | const argsArrays = argsObjects.map(returnArrayFromArgs); 12 | 13 | console.log("\n"); 14 | 15 | const suiteName = "Destructuring"; 16 | 17 | new Benchmark.Suite(suiteName) 18 | .add(`array destructuring`, function() { 19 | const allUseIt: any[] = []; 20 | 21 | for (const arg of argsArrays) { 22 | const [limit, queryString, isItGood, anything] = arg; 23 | const useIt = `${limit}${queryString}${isItGood}${anything}`; 24 | allUseIt.push(useIt); 25 | } 26 | 27 | return allUseIt; 28 | }) 29 | .add(`object destructuring (no renames)`, function() { 30 | const allUseIt: any[] = []; 31 | 32 | for (const arg of argsObjects) { 33 | const { limit, queryString, isItGood, anything } = arg; 34 | const useIt = `${limit}${queryString}${isItGood}${anything}`; 35 | allUseIt.push(useIt); 36 | } 37 | 38 | return allUseIt; 39 | }) 40 | .add(`object destructuring with renaming`, function() { 41 | const allUseIt: any[] = []; 42 | 43 | for (const arg of argsObjectsTwo) { 44 | const { 45 | limit: renamedLimit, 46 | queryString: renamedQueryString, 47 | isItGood: renamedIsItGood, 48 | anything: renamedAnything, 49 | } = arg; 50 | const useIt = `${renamedLimit}${renamedQueryString}${renamedIsItGood}${renamedAnything}`; 51 | allUseIt.push(useIt); 52 | } 53 | 54 | return allUseIt; 55 | }) 56 | .on("error", function(event) { 57 | console.log(`An error occurred`); 58 | console.log(String(event.target)); 59 | }) 60 | .on("cycle", function(event) { 61 | console.log(String(event.target)); 62 | }) 63 | .on("complete", function() { 64 | console.log(`\n${suiteName} - Fastest is ` + this.filter("fastest").map("name")); 65 | }) 66 | .run(); 67 | 68 | /* 69 | * array destructuring x 26,493 ops/sec ±0.57% (97 runs sampled) 70 | object destructuring x 28,591 ops/sec ±0.28% (94 runs sampled) 71 | object destructuring with renaming x 28,267 ops/sec ±0.37% (94 runs sampled) 72 | * */ -------------------------------------------------------------------------------- /test/benchmark/benchmark-immer-with-stores.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { createRandomArgs, IRandomArgObject } from "./BenchmarkUtils"; 3 | import { produce, setAutoFreeze } from "immer"; 4 | import { Store } from "../../src"; 5 | 6 | const amount = 10; 7 | 8 | const FirstStore = new Store({ 9 | objectSet: createRandomArgs(amount), 10 | }); 11 | const firstStoreObjectSetChanges = createRandomArgs(amount); 12 | FirstStore._setInternalOptions({ ssr: true }); 13 | 14 | const SecondStore = new Store({ 15 | objectSet: createRandomArgs(amount), 16 | }); 17 | const secondStoreObjectSetChanges = createRandomArgs(amount); 18 | SecondStore._setInternalOptions({ ssr: true }); 19 | 20 | const ThirdStore = new Store({ 21 | objectSet: createRandomArgs(amount), 22 | }); 23 | const thirdStoreObjectSetChanges = createRandomArgs(amount); 24 | ThirdStore._setInternalOptions({ ssr: true }); 25 | 26 | const FourthStore = new Store({ 27 | objectSet: createRandomArgs(amount), 28 | }); 29 | const fourthStoreObjectSetChanges = createRandomArgs(amount); 30 | FourthStore._setInternalOptions({ ssr: true }); 31 | 32 | console.log("\n"); 33 | 34 | const suiteName = "Immer with stores updates"; 35 | 36 | new Benchmark.Suite(suiteName) 37 | .add(`with default auto-freeze (true) and no original`, function() { 38 | setAutoFreeze(true); 39 | 40 | FirstStore.update(s => { 41 | for (const [index, arg] of s.objectSet.entries()) { 42 | const randomChanges = firstStoreObjectSetChanges[index]; 43 | 44 | arg.anything = randomChanges.anything; 45 | arg.limit = arg.limit * 100 * randomChanges.limit; 46 | arg.isItGood = !arg.isItGood && randomChanges.isItGood; 47 | arg.queryString = `${arg.queryString}${randomChanges.queryString}`; 48 | } 49 | }); 50 | }) 51 | .add(`with no auto-freeze (false) and no original`, function() { 52 | setAutoFreeze(false); 53 | 54 | SecondStore.update(s => { 55 | for (const [index, arg] of s.objectSet.entries()) { 56 | const randomChanges = secondStoreObjectSetChanges[index]; 57 | 58 | arg.anything = randomChanges.anything; 59 | arg.limit = arg.limit * 100 * randomChanges.limit; 60 | arg.isItGood = !arg.isItGood && randomChanges.isItGood; 61 | arg.queryString = `${arg.queryString}${randomChanges.queryString}`; 62 | } 63 | }); 64 | }) 65 | .add(`with default auto-freeze (true) and using original`, function() { 66 | setAutoFreeze(true); 67 | 68 | ThirdStore.update((s, original) => { 69 | for (const [index, arg] of original.objectSet.entries()) { 70 | const randomChanges = thirdStoreObjectSetChanges[index]; 71 | 72 | s.objectSet[index] = { 73 | anything: randomChanges.anything, 74 | limit: arg.limit * 100 * randomChanges.limit, 75 | isItGood: !arg.isItGood && randomChanges.isItGood, 76 | queryString: `${arg.queryString}${randomChanges.queryString}`, 77 | }; 78 | 79 | /*s.objectSet[index].anything = randomChanges.anything; 80 | s.objectSet[index].limit = arg.limit * 100 * randomChanges.limit; 81 | s.objectSet[index].isItGood = !arg.isItGood && randomChanges.isItGood; 82 | s.objectSet[index].queryString = `${arg.queryString}${randomChanges.queryString}`;*/ 83 | } 84 | }); 85 | }) 86 | .add(`with no auto-freeze (false) and using original`, function() { 87 | setAutoFreeze(false); 88 | 89 | FourthStore.update((s, original) => { 90 | for (const [index, arg] of original.objectSet.entries()) { 91 | const randomChanges = fourthStoreObjectSetChanges[index]; 92 | 93 | s.objectSet[index] = { 94 | anything: randomChanges.anything, 95 | limit: arg.limit * 100 * randomChanges.limit, 96 | isItGood: !arg.isItGood && randomChanges.isItGood, 97 | queryString: `${arg.queryString}${randomChanges.queryString}`, 98 | }; 99 | 100 | /*s.objectSet[index].anything = randomChanges.anything; 101 | s.objectSet[index].limit = arg.limit * 100 * randomChanges.limit; 102 | s.objectSet[index].isItGood = !arg.isItGood && randomChanges.isItGood; 103 | s.objectSet[index].queryString = `${arg.queryString}${randomChanges.queryString}`;*/ 104 | } 105 | }); 106 | }) 107 | .add(`with default auto-freeze (true) and full array change using original`, function() { 108 | setAutoFreeze(true); 109 | 110 | ThirdStore.update((s, original) => { 111 | s.objectSet = original.objectSet.map((arg, index) => { 112 | const randomChanges = thirdStoreObjectSetChanges[index]; 113 | 114 | return { 115 | anything: randomChanges.anything, 116 | limit: arg.limit * 100 * randomChanges.limit, 117 | isItGood: !arg.isItGood && randomChanges.isItGood, 118 | queryString: `${arg.queryString}${randomChanges.queryString}`, 119 | }; 120 | }); 121 | }); 122 | }) 123 | .add(`with no auto-freeze (false) and full array change using original`, function() { 124 | setAutoFreeze(false); 125 | 126 | FourthStore.update((s, original) => { 127 | s.objectSet = original.objectSet.map((arg, index) => { 128 | const randomChanges = thirdStoreObjectSetChanges[index]; 129 | 130 | return { 131 | anything: randomChanges.anything, 132 | limit: arg.limit * 100 * randomChanges.limit, 133 | isItGood: !arg.isItGood && randomChanges.isItGood, 134 | queryString: `${arg.queryString}${randomChanges.queryString}`, 135 | }; 136 | }); 137 | }); 138 | }) 139 | .on("error", function(event) { 140 | console.log(`An error occurred`); 141 | console.log(event); 142 | }) 143 | .on("cycle", function(event) { 144 | console.log(String(event.target)); 145 | }) 146 | .on("complete", function() { 147 | console.log(`\n${suiteName} - Fastest is ` + this.filter("fastest").map("name")); 148 | }) 149 | .run({ async: true }); 150 | 151 | /* 152 | * array destructuring x 26,493 ops/sec ±0.57% (97 runs sampled) 153 | object destructuring x 28,591 ops/sec ±0.28% (94 runs sampled) 154 | object destructuring with renaming x 28,267 ops/sec ±0.37% (94 runs sampled) 155 | * */ 156 | -------------------------------------------------------------------------------- /test/benchmark/benchmark-immer-without-stores.ts: -------------------------------------------------------------------------------- 1 | import Benchmark from "benchmark"; 2 | import { createRandomArgs, IRandomArgObject } from "./BenchmarkUtils"; 3 | import { produce, setAutoFreeze } from "immer"; 4 | 5 | const amount = 10; 6 | 7 | const firstObjectSet = { objectSet: createRandomArgs(amount) }; 8 | const firstObjectSetChanges = createRandomArgs(amount); 9 | 10 | const secondObjectSet = { objectSet: createRandomArgs(amount) }; 11 | const secondObjectSetChanges = createRandomArgs(amount); 12 | 13 | const thirdObjectSet = { objectSet: createRandomArgs(amount) }; 14 | const thirdObjectSetChanges = createRandomArgs(amount); 15 | 16 | const fourthObjectSet = { objectSet: createRandomArgs(amount) }; 17 | const fourthObjectSetChanges = createRandomArgs(amount); 18 | 19 | console.log("\n"); 20 | 21 | const suiteName = "Immer produce() usage"; 22 | 23 | new Benchmark.Suite(suiteName) 24 | .add(`with default auto-freeze (true) and no original`, function() { 25 | setAutoFreeze(true); 26 | 27 | const allUseIt: any[] = []; 28 | 29 | for (const [index, arg] of firstObjectSet.objectSet.entries()) { 30 | const randomChanges = firstObjectSetChanges[index]; 31 | 32 | const useIt = produce(arg, s => { 33 | s.anything = randomChanges.anything; 34 | s.limit = s.limit * 100 * randomChanges.limit; 35 | s.isItGood = !s.isItGood && randomChanges.isItGood; 36 | s.queryString = `${s.queryString}${randomChanges.queryString}`; 37 | }); 38 | 39 | allUseIt.push(useIt); 40 | } 41 | 42 | return allUseIt; 43 | }) 44 | .add(`with no auto-freeze (false) and no original`, function() { 45 | setAutoFreeze(false); 46 | 47 | const allUseIt: any[] = []; 48 | 49 | for (const [index, arg] of secondObjectSet.objectSet.entries()) { 50 | const randomChanges = secondObjectSetChanges[index]; 51 | 52 | const useIt = produce(arg, s => { 53 | s.anything = randomChanges.anything; 54 | s.limit = s.limit * 100 * randomChanges.limit; 55 | s.isItGood = !s.isItGood && randomChanges.isItGood; 56 | s.queryString = `${s.queryString}${randomChanges.queryString}`; 57 | }); 58 | 59 | allUseIt.push(useIt); 60 | } 61 | 62 | return allUseIt; 63 | }) 64 | .add(`with default auto-freeze (true) and using original`, function() { 65 | setAutoFreeze(true); 66 | 67 | const allUseIt: any[] = []; 68 | 69 | for (const [index, arg] of thirdObjectSet.objectSet.entries()) { 70 | const randomChanges = thirdObjectSetChanges[index]; 71 | 72 | const useIt = produce(arg, s => { 73 | s.anything = randomChanges.anything; 74 | s.limit = arg.limit * 100 * randomChanges.limit; 75 | s.isItGood = !arg.isItGood && randomChanges.isItGood; 76 | s.queryString = `${arg.queryString}${randomChanges.queryString}`; 77 | }); 78 | 79 | allUseIt.push(useIt); 80 | } 81 | 82 | return allUseIt; 83 | }) 84 | .add(`with no auto-freeze (false) and using original`, function() { 85 | setAutoFreeze(false); 86 | 87 | const allUseIt: any[] = []; 88 | 89 | for (const [index, arg] of fourthObjectSet.objectSet.entries()) { 90 | const randomChanges = fourthObjectSetChanges[index]; 91 | 92 | const useIt = produce(arg, s => { 93 | s.anything = randomChanges.anything; 94 | s.limit = arg.limit * 100 * randomChanges.limit; 95 | s.isItGood = !arg.isItGood && randomChanges.isItGood; 96 | s.queryString = `${arg.queryString}${randomChanges.queryString}`; 97 | }); 98 | 99 | allUseIt.push(useIt); 100 | } 101 | 102 | return allUseIt; 103 | }) 104 | .add(`with no auto-freeze (false) and using original - producing entire inner array once from original`, function() { 105 | setAutoFreeze(false); 106 | 107 | const result = produce(fourthObjectSet, s => { 108 | s.objectSet = fourthObjectSet.objectSet.map((o, i) => { 109 | const randomChanges = fourthObjectSetChanges[i]; 110 | 111 | return { 112 | anything: randomChanges.anything, 113 | limit: o.limit * 100 * randomChanges.limit, 114 | isItGood: !o.isItGood && randomChanges.isItGood, 115 | queryString: `${o.queryString}${randomChanges.queryString}` 116 | }; 117 | }) 118 | }); 119 | }) 120 | .on("error", function(event) { 121 | console.log(`An error occurred`); 122 | console.log(String(event.target)); 123 | }) 124 | .on("cycle", function(event) { 125 | console.log(String(event.target)); 126 | }) 127 | .on("complete", function() { 128 | console.log(`\n${suiteName} - Fastest is ` + this.filter("fastest").map("name")); 129 | }) 130 | .run(); 131 | 132 | /* 133 | * array destructuring x 26,493 ops/sec ±0.57% (97 runs sampled) 134 | object destructuring x 28,591 ops/sec ±0.28% (94 runs sampled) 135 | object destructuring with renaming x 28,267 ops/sec ±0.37% (94 runs sampled) 136 | * */ -------------------------------------------------------------------------------- /test/benchmark/keyFromObjectImplementations.ts: -------------------------------------------------------------------------------- 1 | function keyFromObjectTemplate(json) { 2 | if (json == null) { 3 | return `${json}`; 4 | } 5 | 6 | if (typeof json !== "object") { 7 | return `${json}`; 8 | } 9 | 10 | let prefix = "{"; 11 | 12 | for (const key of Object.keys(json).sort()) { 13 | prefix += key; 14 | 15 | if (typeof json[key] === "undefined") { 16 | prefix += "(und)"; 17 | } else if (typeof json[key] === "string") { 18 | prefix += `:${json[key]};`; 19 | } else if (typeof json[key] === "boolean" || typeof json[key] === "number") { 20 | prefix += `(${json[key]})`; 21 | } else { 22 | prefix += keyFromObjectTemplate(json[key]); 23 | } 24 | } 25 | 26 | return prefix + "}"; 27 | } 28 | 29 | function keyFromObjectConcat(json) { 30 | if (json == null) { 31 | return "" + json; 32 | } 33 | 34 | if (typeof json !== "object") { 35 | return "" + json; 36 | } 37 | 38 | let prefix = "{"; 39 | 40 | for (const key of Object.keys(json).sort()) { 41 | prefix += key; 42 | 43 | if (typeof json[key] === "undefined") { 44 | prefix += "(und)"; 45 | } else if (typeof json[key] === "string") { 46 | prefix += ":" + json[key] + ";"; 47 | } else if (typeof json[key] === "boolean" || typeof json[key] === "number") { 48 | prefix += "(" + json[key] + ")"; 49 | } else { 50 | prefix += keyFromObjectConcat(json[key]); 51 | } 52 | } 53 | 54 | return prefix + "}"; 55 | } 56 | 57 | function keyFromObjectConcatNew(json) { 58 | if (json === null) { 59 | return "(n)"; 60 | } 61 | 62 | const typeOf = typeof json; 63 | 64 | if (typeOf !== "object") { 65 | if (typeOf === "undefined") { 66 | return "(u)"; 67 | } else if (typeOf === "string") { 68 | return ":" + json + ";"; 69 | } else if (typeOf === "boolean" || typeOf === "number") { 70 | return "(" + json + ")"; 71 | } 72 | } 73 | 74 | let prefix = "{"; 75 | 76 | for (const key of Object.keys(json).sort()) { 77 | prefix += key + keyFromObjectConcatNew(json[key]); 78 | } 79 | 80 | return prefix + "}"; 81 | } 82 | 83 | export const keyFromObjectImplementations = { 84 | keyFromObjectConcat, 85 | keyFromObjectTemplate, 86 | keyFromObjectConcatNew, 87 | }; 88 | -------------------------------------------------------------------------------- /test/dist-types/TestDistTypes.ts: -------------------------------------------------------------------------------- 1 | import { Store, useStoreStateOpt } from "../../dist"; 2 | 3 | const obj = { 4 | inner: { 5 | something: "great", 6 | innerTwo: { 7 | isIt: true, 8 | }, 9 | }, 10 | innerArr: [{ 11 | bogus: true, 12 | }], 13 | firstLevel: "", 14 | }; 15 | 16 | export interface IPostSearchStore { 17 | posts: any[]; 18 | currentSearchText: string; 19 | loadingPosts: boolean; 20 | } 21 | 22 | export const PostSearchStore = new Store({ 23 | posts: [], 24 | currentSearchText: "", 25 | loadingPosts: false, 26 | }); 27 | 28 | const store = new Store(obj); 29 | 30 | const [posts, text] = useStoreStateOpt(PostSearchStore, [["posts"], ["currentSearchText"]]); 31 | 32 | function takeString(take: string) { 33 | 34 | } 35 | 36 | takeString(text); 37 | -------------------------------------------------------------------------------- /test/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ['**/tests/*.ts?(x)'], 6 | }; 7 | -------------------------------------------------------------------------------- /test/old_jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bail: false, 3 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 4 | moduleDirectories: ["node_modules", "../node_modules"], 5 | testEnvironment: "jsdom", 6 | testRegex: "((test|spec)|(tests|specs))\\.(jsx?|tsx?)$", 7 | transform: { 8 | "^.+\\.tsx?$": "ts-jest", 9 | }, 10 | verbose: true, 11 | setupFilesAfterEnv: ["./rtl.setup.ts"], 12 | }; 13 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pullstate-tests", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "react": "^18.2.0", 6 | "react-dom": "^18.2.0", 7 | "@testing-library/react": "^13.4.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/rtl.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; -------------------------------------------------------------------------------- /test/test.umd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test Minified 5 | 6 | 7 | 8 | 9 | 10 |