├── .dockerignore ├── test ├── rtl.setup.ts ├── benchmark │ ├── benchmark-all-immer.ts │ ├── all.ts │ ├── BenchmarkUtils.ts │ ├── keyFromObjectImplementations.ts │ ├── benchmark-destructuring.ts │ ├── benchmark-async-argument.ts │ ├── benchmark-immer-without-stores.ts │ └── benchmark-immer-with-stores.ts ├── tests │ ├── TestUtils.ts │ ├── __snapshots__ │ │ ├── ssr.tests.tsx.snap │ │ ├── client.tests.tsx.snap │ │ └── async.tests.tsx.snap │ ├── testStores │ │ └── TestUIStore.ts │ ├── core.tests.tsx │ ├── opt-state-listeners.tests.tsx │ ├── ssr.async.tests.tsx │ ├── basic-store.tests.tsx │ ├── TestSetup.ts │ ├── client.tests.tsx │ ├── ssr.tests.tsx │ └── async.tests.tsx ├── jest.config.js ├── package.json ├── tsconfig.json ├── old_jest.config.js ├── test.umd.html └── dist-types │ └── TestDistTypes.ts ├── .prettierrc ├── graphics ├── Icon.png ├── logo.png ├── logo-new.png ├── Icon on Light.png ├── logo-newest.png └── pullstate-logo.png ├── .gitignore ├── docs ├── assets │ └── async-flow.png ├── installation.md ├── redux-dev-tools.md ├── _removed.md ├── async-actions-other-options.md ├── async-short-circuit-hook.md ├── async-actions-introduction.md ├── async-cache-clearing.md ├── async-cache-break-hook.md ├── inject-store-state.md ├── async-hooks-overview.md ├── update-store.md ├── subscribe.md ├── use-store-state-hook.md ├── reactions.md ├── quick-example.md ├── async-post-action-hook.md ├── async-server-rendering.md ├── quick-example-server-rendered.md ├── async-actions-creating.md └── async-action-use.md ├── website ├── static │ ├── img │ │ ├── favicon.png │ │ ├── logo-new.png │ │ ├── oss_logo.png │ │ ├── async-flow.png │ │ ├── logo-newest.png │ │ ├── favicon │ │ │ └── favicon.ico │ │ ├── logo-ondark-small.png │ │ ├── icon-transparent-ondark.png │ │ ├── icon-transparent-onlight.png │ │ ├── icon-transparent-ondark-new.png │ │ └── icon-transparent-onlight-new.png │ └── css │ │ └── custom.css ├── package.json ├── sidebars.json ├── core │ └── Footer.js ├── siteConfig.js ├── i18n │ └── en.json ├── pages │ └── en │ │ └── index.js └── README.md ├── src ├── fastDeepEqual.d.ts ├── globalClientState.ts ├── InjectStoreState.ts ├── useLocalStore.ts ├── batch.ts ├── reduxDevtools.ts ├── index.ts ├── InjectAsyncAction.ts ├── InjectStoreStateOpt.ts ├── useStoreStateOpt.ts ├── useStoreStateOpt-types.ts ├── useStoreState.ts ├── PullstateCore.tsx └── async-types.ts ├── Dockerfile ├── typedoc.json ├── typedoc.tsconfig.json ├── tsconfig.json ├── docker-compose.yml ├── .github └── FUNDING.yml ├── LICENSE ├── rollup.config.js ├── package.json ├── README.md └── Changelog.md /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /test/rtl.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5" 4 | } -------------------------------------------------------------------------------- /graphics/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/Icon.png -------------------------------------------------------------------------------- /graphics/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | dist 4 | yarn-error.log 5 | .rpt2_cache 6 | website/build -------------------------------------------------------------------------------- /graphics/logo-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/logo-new.png -------------------------------------------------------------------------------- /docs/assets/async-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/docs/assets/async-flow.png -------------------------------------------------------------------------------- /graphics/Icon on Light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/Icon on Light.png -------------------------------------------------------------------------------- /graphics/logo-newest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/logo-newest.png -------------------------------------------------------------------------------- /graphics/pullstate-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/graphics/pullstate-logo.png -------------------------------------------------------------------------------- /website/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/favicon.png -------------------------------------------------------------------------------- /website/static/img/logo-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/logo-new.png -------------------------------------------------------------------------------- /website/static/img/oss_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/oss_logo.png -------------------------------------------------------------------------------- /test/benchmark/benchmark-all-immer.ts: -------------------------------------------------------------------------------- 1 | import "./benchmark-immer-without-stores"; 2 | import "./benchmark-immer-with-stores"; -------------------------------------------------------------------------------- /website/static/img/async-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/async-flow.png -------------------------------------------------------------------------------- /website/static/img/logo-newest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/logo-newest.png -------------------------------------------------------------------------------- /website/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /website/static/img/logo-ondark-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/logo-ondark-small.png -------------------------------------------------------------------------------- /src/fastDeepEqual.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fast-deep-equal/es6" { 2 | const equal: (a: any, b: any) => boolean; 3 | export = equal; 4 | } 5 | -------------------------------------------------------------------------------- /website/static/img/icon-transparent-ondark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/icon-transparent-ondark.png -------------------------------------------------------------------------------- /website/static/img/icon-transparent-onlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/icon-transparent-onlight.png -------------------------------------------------------------------------------- /website/static/img/icon-transparent-ondark-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/icon-transparent-ondark-new.png -------------------------------------------------------------------------------- /website/static/img/icon-transparent-onlight-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lostpebble/pullstate/HEAD/website/static/img/icon-transparent-onlight-new.png -------------------------------------------------------------------------------- /test/tests/TestUtils.ts: -------------------------------------------------------------------------------- 1 | export async function waitSeconds(seconds: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, 1000 * seconds); 4 | }); 5 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/__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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/test.umd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test Minified 5 | 6 | 7 | 8 | 9 | 10 |

Hi

11 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /website/static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* your custom css */ 2 | 3 | @media only screen and (min-device-width: 360px) and (max-device-width: 736px) { 4 | } 5 | 6 | @media only screen and (min-width: 1024px) { 7 | } 8 | 9 | @media only screen and (max-width: 1023px) { 10 | } 11 | 12 | @media only screen and (min-width: 1400px) { 13 | } 14 | 15 | @media only screen and (min-width: 1500px) { 16 | } 17 | 18 | .nav-footer .pullstate-logo { 19 | display: block; 20 | margin: 1em auto; 21 | opacity: 0.4; 22 | transition: opacity 0.15s ease-in-out; 23 | width: 210px; 24 | height: 141px; 25 | } 26 | 27 | .nav-footer .pullstate-logo:hover { 28 | opacity: 1; 29 | } 30 | 31 | .navPusher { 32 | display: flex; 33 | flex-direction: column; 34 | flex-grow: 1; 35 | min-height: calc(100vh - 50px); 36 | } 37 | 38 | .homeContainer { 39 | display: flex; 40 | flex-grow: 1; 41 | align-items: center; 42 | justify-content: center; 43 | flex-direction: column; 44 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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**! -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | * */ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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/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/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 | -------------------------------------------------------------------------------- /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

, 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 | -------------------------------------------------------------------------------- /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

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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------