├── .gitignore ├── .prettierrc ├── .editorconfig ├── .changeset └── config.json ├── tsconfig.json ├── jest.config.js ├── test ├── hooks.server.test.tsx ├── store.test.ts └── hooks.test.tsx ├── LICENSE.md ├── .github └── workflows │ ├── tests.yml │ └── release.yml ├── package.json ├── CHANGELOG.md ├── src └── index.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | **/*.log 3 | /dist/ 4 | /docs/ 5 | /.vscode 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": false, 7 | "arrowParens": "always", 8 | "printWidth": 100 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | tab_width = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.1.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "strict": true, 8 | "jsx": "react-jsx", 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | preset: "ts-jest", 4 | testMatch: ["**/?(*.)+(spec|test).+(ts|tsx)"], 5 | testPathIgnorePatterns: ["node_modules"], 6 | testEnvironment: "jsdom", 7 | moduleFileExtensions: ["js", "ts", "tsx"], 8 | transform: { 9 | "^.+\\.tsx?$": [ 10 | "ts-jest", 11 | { 12 | isolatedModules: true, 13 | babel: true, 14 | tsconfig: "tsconfig.json" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/hooks.server.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { renderToPipeableStream } from "react-dom/server" 3 | import { PassThrough } from "stream" 4 | import { makeStore, useStore } from "../src" 5 | 6 | function renderServerSide(element: React.ReactElement) { 7 | return new Promise((resolve, reject) => { 8 | let result = "" 9 | renderToPipeableStream(element) 10 | .pipe(new PassThrough()) 11 | .on("data", (chunk) => (result += chunk)) 12 | .on("end", () => resolve(result)) 13 | .on("error", reject) 14 | }) 15 | } 16 | 17 | describe("useStore", () => { 18 | it("renders server-side", async () => { 19 | const store = makeStore({ ssr: "yep" }) 20 | 21 | const SSRComponent = () => { 22 | const { ssr } = useStore(store) 23 | return
{ssr}
24 | } 25 | 26 | const html = await renderServerSide() 27 | 28 | expect(html).toEqual("
yep
") 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Hendrik Mans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: {} 8 | 9 | jobs: 10 | build: 11 | name: Build and Run Tests 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16 22 | 23 | - uses: pnpm/action-setup@v2.0.1 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 34 | 35 | - uses: actions/cache@v3 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Run CI task 47 | run: pnpm ci:test 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Stable Release / Version PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16 22 | 23 | - uses: pnpm/action-setup@v2.0.1 24 | name: Install pnpm 25 | id: pnpm-install 26 | with: 27 | version: 8 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 34 | 35 | - uses: actions/cache@v3 36 | name: Setup pnpm cache 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Create Release Pull Request or Publish to npm 47 | id: changesets 48 | uses: changesets/action@v1 49 | with: 50 | version: pnpm ci:version 51 | publish: pnpm ci:release 52 | # commit: "chore: update versions" 53 | # title: "chore: update versions" 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | # - name: Send a Slack notification if a publish happens 58 | # if: steps.changesets.outputs.published == 'true' 59 | # # You can do something when a publish happens. 60 | # run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statery", 3 | "author": { 4 | "name": "Hendrik Mans", 5 | "email": "hendrik@mans.de", 6 | "url": "https://hmans.dev" 7 | }, 8 | "description": "A happy little state management library for React and friends.", 9 | "homepage": "https://github.com/hmans/statery", 10 | "keywords": [ 11 | "react", 12 | "state", 13 | "state-management", 14 | "hooks" 15 | ], 16 | "sideEffects": false, 17 | "version": "0.7.1", 18 | "main": "dist/statery.cjs.js", 19 | "module": "dist/statery.esm.js", 20 | "types": "dist/statery.cjs.d.ts", 21 | "files": [ 22 | "dist/", 23 | "README.md", 24 | "LICENSE.md" 25 | ], 26 | "license": "MIT", 27 | "babel": { 28 | "comments": false, 29 | "presets": [ 30 | "@babel/preset-env", 31 | "@babel/preset-react", 32 | "@babel/preset-typescript" 33 | ] 34 | }, 35 | "scripts": { 36 | "clean": "rimraf dist", 37 | "dev": "preconstruct dev", 38 | "build": "preconstruct build", 39 | "test:client": "jest --testPathIgnorePatterns=.server.test", 40 | "test:server": "jest --testPathPattern=.server.test --env=node", 41 | "test": "pnpm test:client && pnpm test:server", 42 | "ci:test": "preconstruct validate && pnpm build && pnpm test", 43 | "ci:version": "changeset version && pnpm install --no-frozen-lockfile", 44 | "ci:release": "pnpm ci:test && pnpm changeset publish" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "^7.22.9", 48 | "@babel/preset-env": "^7.22.9", 49 | "@babel/preset-react": "^7.22.5", 50 | "@babel/preset-typescript": "^7.22.5", 51 | "@changesets/cli": "^2.26.2", 52 | "@preconstruct/cli": "^2.8.1", 53 | "@testing-library/react": "^14.0.0", 54 | "@types/jest": "^29.5.3", 55 | "@types/react": "^18.2.18", 56 | "@types/react-dom": "^18.2.7", 57 | "jest": "^29.6.2", 58 | "jest-environment-jsdom": "^29.6.2", 59 | "react": "^18.1.0", 60 | "react-dom": "^18.1.0", 61 | "rimraf": "^5.0.1", 62 | "ts-jest": "^29.1.1", 63 | "tslib": "^2.6.1", 64 | "typescript": "^5.1.6" 65 | }, 66 | "peerDependencies": { 67 | "react": ">=18.0" 68 | }, 69 | "peerDependenciesMeta": { 70 | "react": { 71 | "optional": true 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/store.test.ts: -------------------------------------------------------------------------------- 1 | import { makeStore } from "../src" 2 | 3 | describe("makeStore", () => { 4 | const init = () => ({ 5 | foo: 0, 6 | bar: 0, 7 | active: false 8 | }) 9 | 10 | const store = makeStore(init()) 11 | 12 | beforeEach(() => { 13 | store.set(init) 14 | }) 15 | 16 | describe(".state", () => { 17 | it("provides direct access to the state object", () => { 18 | expect(store.state).toEqual({ foo: 0, bar: 0, active: false }) 19 | }) 20 | }) 21 | 22 | describe(".set", () => { 23 | it("accepts a dictionary of updates to the state", () => { 24 | store.set({ foo: 10 }) 25 | expect(store.state.foo).toEqual(10) 26 | }) 27 | 28 | it("accepts a function that accepts the state and returns an update dictionary", () => { 29 | const current = store.state.foo 30 | expect(store.state.foo).toEqual(current) 31 | store.set(({ foo }) => ({ foo: foo + 1 })) 32 | expect(store.state.foo).toEqual(current + 1) 33 | }) 34 | 35 | it("returns the updated state", () => { 36 | const result = store.set({ foo: 1 }) 37 | expect(result).toEqual({ foo: 1, bar: 0, active: false }) 38 | }) 39 | 40 | it("supports async updates to the state", async () => { 41 | const doImportantWork = () => new Promise((resolve) => setTimeout(resolve, 2000)) 42 | 43 | const asyncUpdater = async () => { 44 | await doImportantWork() 45 | store.set({ foo: 1 }) 46 | } 47 | 48 | expect(store.state.foo).toBe(0) 49 | await asyncUpdater() 50 | expect(store.state.foo).toBe(1) 51 | }) 52 | 53 | it("if no new values are applied, the state stays untouched", () => { 54 | const oldState = store.state 55 | store.set({ foo: 0 }) 56 | expect(store.state).toBe(oldState) 57 | }) 58 | 59 | it("if something actually changes, the state become a new object", () => { 60 | const oldState = store.state 61 | store.set({ foo: 1 }) 62 | expect(store.state).not.toBe(oldState) 63 | }) 64 | }) 65 | 66 | describe(".subscribe", () => { 67 | it("accepts a listener callback that will be invoked when the store changes", () => { 68 | const listener = jest.fn() 69 | store.subscribe(listener) 70 | store.set({ foo: 1 }) 71 | store.unsubscribe(listener) 72 | 73 | /* It should have been called exactly once */ 74 | expect(listener.mock.calls.length).toBe(1) 75 | 76 | /* The first argument should be the changes */ 77 | expect(listener.mock.calls[0][0]).toEqual({ foo: 1 }) 78 | 79 | /* The second argument should be the previous state */ 80 | expect(listener.mock.calls[0][1]).toEqual({ foo: 0, bar: 0, active: false }) 81 | }) 82 | 83 | it("allows subscribing to updates to a store", () => { 84 | const changeCounters = { 85 | foo: 0, 86 | bar: 0 87 | } 88 | 89 | const listener = (updates) => { 90 | for (const prop in updates) changeCounters[prop]++ 91 | } 92 | 93 | store.subscribe(listener) 94 | 95 | store.set(({ foo }) => ({ foo: foo + 1 })) 96 | store.set(({ foo, bar }) => ({ foo: foo + 1, bar: bar + 1 })) 97 | 98 | store.unsubscribe(listener) 99 | 100 | expect(changeCounters.foo).toEqual(2) 101 | expect(changeCounters.bar).toEqual(1) 102 | }) 103 | 104 | it("only receives actual changes made to the store", () => { 105 | const listener = jest.fn() 106 | store.subscribe(listener) 107 | 108 | store.set({ foo: 1, bar: 0, active: true }) 109 | 110 | /* Updates only contain props that have actually changed */ 111 | expect(listener.mock.calls[0][0]).toEqual({ foo: 1, active: true }) 112 | 113 | /* The second argument is the previous state */ 114 | expect(listener.mock.calls[0][1]).toEqual({ foo: 0, bar: 0, active: false }) 115 | 116 | /* The state has actually been updated */ 117 | expect(store.state).toEqual({ foo: 1, bar: 0, active: true }) 118 | 119 | store.unsubscribe(listener) 120 | }) 121 | 122 | it("receives all changes made to the store if the `force` flag is set", () => { 123 | const listener = jest.fn() 124 | store.subscribe(listener) 125 | 126 | /* We're setting both foo and bar; only foo is actually a new value. */ 127 | store.set({ foo: 1, bar: 0 }, { forceNotify: true }) 128 | 129 | /* Since we've forced the update, the changes now include the un-changed `bar`, as well */ 130 | expect(listener.mock.calls[0][0]).toEqual({ foo: 1, bar: 0 }) 131 | 132 | store.unsubscribe(listener) 133 | }) 134 | 135 | it("already makes the updated state available to listeners", () => { 136 | let newValue: number | undefined = undefined 137 | let prevValue: number | undefined = undefined 138 | 139 | const listener = (_, prevState) => { 140 | newValue = store.state.foo 141 | prevValue = prevState.foo 142 | } 143 | 144 | store.subscribe(listener) 145 | store.set({ foo: 1 }) 146 | store.unsubscribe(listener) 147 | 148 | expect(prevValue).toBe(0) 149 | expect(newValue).toBe(1) 150 | }) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.7.1 4 | 5 | ### Patch Changes 6 | 7 | - f18b084: Fixes SSR issue with Next.js requiring the getServerSnapshot argument with useSyncExternalStore. 8 | 9 | ## 0.7.0 10 | 11 | ### Minor Changes 12 | 13 | - 8928b16: Statery's React hooks now internally use React 18's new `useSyncExternalStore` hook. This simplifies the library implementation and makes sure that store updates don't cause UI drift. (Thanks @smartinio!) 14 | - 8c555e2: This package is now built using TypeScript 5.1. 15 | 16 | ## 0.6.3 17 | 18 | ### Patch Changes 19 | 20 | - a9923df: **Fixed:** The library recently started using `useLayoutEffect` instead of `useEffect`, breaking it in SSR environments, where usage of that hook throws errors. This has been fixed. (Thanks @daveschumaker!) 21 | 22 | ## 0.6.2 23 | 24 | ### Patch Changes 25 | 26 | - 5c8de24: Revert the previous fix for potentially infinite rerenders -- turns out it was a user issue (the user being me!), and the fix would actually cause bigger problems elsewhere. 27 | 28 | ## 0.6.1 29 | 30 | ### Patch Changes 31 | 32 | - c69ab20: Updating the same state property multiple times within the same React side effect would sometimes trigger multiple rerenders of the component, resulting in an infinite loop (for example when writing into the store in a `ref` function.) This has now been fixed. 33 | 34 | ## 0.6.0 35 | 36 | ### Minor Changes 37 | 38 | - d2eb9b4: **Fixed:** `useStore` now hooks into the store using `useLayoutEffect`, not `useEffect` 39 | - 53fff47: Refreshed all of the package's dependencies and brushed up its test setup. 40 | - 5b64a96: Switched the library's build tool to [Preconstruct](https://preconstruct.tools/). If everything breaks, it's on me! 🎉 41 | - 53fff47: Statery now requires React 18 and up! 42 | - 75d0a40: Simplify types. 43 | - da27eba: `set` now takes a second options argument. The only available option so far is `forceNotify`; when set to true, all updated properties will be notified, regardless of referential equality to the previous value. 44 | 45 | ### Patch Changes 46 | 47 | - a5f5533: `useStore` will now force the component to re-render if a change was detected between the React render/reconcile stage and the invocation of the layout effect that actually sets up the subscription listener. This improves reactivity in situations where values were changed in the store during the render phase, or imperatively from outside of your React component tree. 48 | 49 | ## 0.6.0-next.3 50 | 51 | ### Minor Changes 52 | 53 | - 5b64a96: Switched the library's build tool to [Preconstruct](https://preconstruct.tools/). If everything breaks, it's on me! 🎉 54 | 55 | ## 0.6.0-next.2 56 | 57 | ### Patch Changes 58 | 59 | - a5f5533: `useStore` will now force the component to re-render if a change was detected between the React render/reconcile stage and the invocation of the layout effect that actually sets up the subscription listener. This improves reactivity in situations where values were changed in the store during the render phase, or imperatively from outside of your React component tree. 60 | 61 | ## 0.6.0-next.1 62 | 63 | ### Minor Changes 64 | 65 | - 75d0a40: Simplify types. 66 | - da27eba: `set` now takes a second argument `forceNotify`; when set to true, all updated properties will be notified, regardless of referential equality to the previous value. 67 | 68 | ## 0.6.0-next.0 69 | 70 | ### Minor Changes 71 | 72 | - d2eb9b4: **Fixed:** `useStore` now hooks into the store using `useLayoutEffect`, not `useEffect` 73 | - 53fff47: Refreshed all of the package's dependencies and brushed up its test setup. 74 | - 53fff47: Statery now requires React 18 and up! 75 | 76 | ## [0.5.4] - 2021-03-20 77 | 78 | - **Changed:** Slightly improved typings. Most importantly, `store.state` is now typed as readonly, which should make Typescript et al warn you if you're trying to directly mutate the state object. 79 | - **Changed:** Statery now uses Typescript 4.2. 80 | 81 | ## [0.5.2] - 2020-12-22 82 | 83 | - **Fixed:** When subscribers were invoked, the store's state had not yet been updated to reflect the incoming changes. This has now been fixed. 84 | 85 | ### [0.5.0 & 0.5.1] - 2020-12-18 86 | 87 | ### Changed 88 | 89 | - **Minor Breaking Change:** The `set` function will now filter incoming updates and discard any that don't actually change the value that is currently in the store. Listeners will only receive the _actual_ changes to a store, and components will only re-render when a watched store property has actually changed to a new value. This change was made to allow for easier [integration with libraries like Immer](https://codesandbox.io/s/statery-immer-vr9b2?file=/src/App.tsx:592-783) that produce a complete new version of the store. 90 | 91 | ## [0.4.0] - 2020-12-17 92 | 93 | ### Added 94 | 95 | - `set` now returns the updated version of the state (instead of `void`.) 96 | 97 | ### Changed 98 | 99 | - **Breaking change:** The nature of `subscribe` callbacks has changed. Whenever a store is updated, each listener callback will now be invoked exactly _once_, with an object containing the applied changes as the first argument, and the current version of the state as the second. 100 | - The bundles generated for the NPM package are now minified through [terser](https://github.com/terser/terser). 101 | 102 | ### Fixed 103 | 104 | - No more "Rendered fewer hooks than expected" surprises! ([#1](https://github.com/hmans/statery/issues/1)) 105 | 106 | ## [0.3.0] - 2020-12-16 107 | 108 | - First release. Exciting times! 109 | -------------------------------------------------------------------------------- /test/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react" 2 | import React, { StrictMode } from "react" 3 | import { makeStore, useStore } from "../src" 4 | 5 | describe("useStore", () => { 6 | it("fetches a piece of data from the store", async () => { 7 | const store = makeStore({ counter: 123 }) 8 | 9 | const Counter = () => { 10 | const { counter } = useStore(store) 11 | return

Counter: {counter}

12 | } 13 | 14 | const { findByText } = render( 15 | 16 | 17 | 18 | ) 19 | 20 | await findByText("Counter: 123") 21 | }) 22 | 23 | it("provides a proxy that allows subscribing to individual properties", async () => { 24 | const store = makeStore({ 25 | wood: 0, 26 | gold: 4, 27 | houses: 0 28 | }) 29 | 30 | const collectWood = () => 31 | store.set((state) => ({ 32 | wood: state.wood + 1 33 | })) 34 | 35 | const sellWood = () => 36 | store.set((state) => ({ 37 | wood: state.wood - 1, 38 | gold: state.gold + 1 39 | })) 40 | 41 | /* Instead of destructuring the store's state in the parameter signature, we will 42 | create a situation where we will return early if state.wood is not >= 5, in order to 43 | test for bugs like this one: https://github.com/hmans/statery/issues/1 */ 44 | const canBuildHouse = (state: typeof store.state) => { 45 | return state.wood >= 5 && state.gold >= 5 46 | } 47 | 48 | const buildHouse = () => 49 | store.set((state) => 50 | canBuildHouse(state) 51 | ? { 52 | wood: state.wood - 5, 53 | gold: state.gold - 5, 54 | houses: state.houses + 1 55 | } 56 | : {} 57 | ) 58 | 59 | let woodRenderCount = 0 60 | let goldRenderCount = 0 61 | let housesRenderCount = 0 62 | let buttonsRenderCount = 0 63 | 64 | const Wood = () => { 65 | woodRenderCount++ 66 | const { wood } = useStore(store) 67 | return

Wood: {wood}

68 | } 69 | 70 | const Gold = () => { 71 | goldRenderCount++ 72 | const { gold } = useStore(store) 73 | return

Gold: {gold}

74 | } 75 | 76 | const Houses = () => { 77 | housesRenderCount++ 78 | const { houses } = useStore(store) 79 | return

Houses: {houses}

80 | } 81 | 82 | const Buttons = () => { 83 | buttonsRenderCount++ 84 | 85 | const proxy = useStore(store) 86 | 87 | return ( 88 |

89 | 90 | 91 | 94 |

95 | ) 96 | } 97 | 98 | const { getByText, findByText } = render( 99 | 100 | 101 | 102 | 103 | 104 | 105 | ) 106 | 107 | await findByText("Wood: 0") 108 | await findByText("Houses: 0") 109 | await findByText("Gold: 4") 110 | 111 | fireEvent.click(getByText("Collect Wood")) 112 | fireEvent.click(getByText("Collect Wood")) 113 | fireEvent.click(getByText("Collect Wood")) 114 | fireEvent.click(getByText("Collect Wood")) 115 | fireEvent.click(getByText("Collect Wood")) 116 | fireEvent.click(getByText("Collect Wood")) 117 | 118 | await findByText("Wood: 6") 119 | 120 | fireEvent.click(getByText("Sell Wood")) 121 | 122 | await findByText("Wood: 5") 123 | await findByText("Gold: 5") 124 | await findByText("Houses: 0") 125 | 126 | fireEvent.click(getByText("Build House")) 127 | 128 | await findByText("Wood: 0") 129 | await findByText("Gold: 0") 130 | await findByText("Houses: 1") 131 | 132 | /* Check how often each component was rendered. Since we're using React's 133 | StrictMode, we need to double these numbers because StrictMode intentionally 134 | renders everything twice. */ 135 | expect(woodRenderCount).toEqual(9 * 2) 136 | expect(housesRenderCount).toEqual(2 * 2) 137 | expect(buttonsRenderCount).toEqual(9 * 2) 138 | }) 139 | 140 | it("should not re-render a component when a watched prop was updated to the same value", async () => { 141 | const store = makeStore({ counter: 0 }) 142 | 143 | let renders = 0 144 | 145 | const increase = () => 146 | store.set(({ counter }) => ({ 147 | counter: counter + 1 148 | })) 149 | 150 | const setToSameValue = () => 151 | store.set(({ counter }) => ({ 152 | counter 153 | })) 154 | 155 | const Counter = () => { 156 | renders++ 157 | 158 | const { counter } = useStore(store) 159 | 160 | return ( 161 | <> 162 |

Counter: {counter}

163 | 164 | 165 | 166 | ) 167 | } 168 | 169 | const { getByText, findByText } = render() 170 | 171 | expect(renders).toBe(1) 172 | await findByText("Counter: 0") 173 | fireEvent.click(getByText("Increase Counter")) 174 | 175 | expect(renders).toBe(2) 176 | await findByText("Counter: 1") 177 | fireEvent.click(getByText("Set to Same Value")) 178 | 179 | expect(renders).toBe(2) 180 | }) 181 | 182 | it("works with boolean state properties", async () => { 183 | const store = makeStore({ active: false }) 184 | 185 | const ActiveDisplay = () => { 186 | const { active } = useStore(store) 187 | return ( 188 | <> 189 |

Active: {active ? "Yes" : "No"}

190 | 191 | 192 | ) 193 | } 194 | 195 | const page = render( 196 | 197 | 198 | 199 | ) 200 | 201 | await page.findByText("Active: No") 202 | fireEvent.click(page.getByText("Toggle")) 203 | await page.findByText("Active: Yes") 204 | fireEvent.click(page.getByText("Toggle")) 205 | await page.findByText("Active: No") 206 | }) 207 | 208 | it("re-renders if a property is changed during the render phase", async () => { 209 | let changedDuringRender = false 210 | let renders = 0 211 | 212 | const store = makeStore({ lightning: "Slow" }) 213 | 214 | const Lightning = () => { 215 | renders++ 216 | const { lightning } = useStore(store) 217 | 218 | if (!changedDuringRender) { 219 | store.set({ lightning: "Fast" }) 220 | changedDuringRender = true 221 | } 222 | 223 | return

Lightning: {lightning}

224 | } 225 | 226 | const page = render() 227 | 228 | await page.findByText("Lightning: Fast") 229 | expect(renders).toBe(2) 230 | }) 231 | }) 232 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useSyncExternalStore } from "react" 2 | 3 | /* 4 | 5 | ███ ▄██ ▄ ▄███████▄ ▄████████ ▄████████ 6 | ▀█████████▄ ███ ██▄ ███ ███ ███ ███ ███ ███ 7 | ▀███▀▀██ ███▄▄▄███ ███ ███ ███ █▀ ███ █▀ 8 | ███ ▀ ▀▀▀▀▀▀███ ███ ███ ▄███▄▄▄ ███ 9 | ███ ▄██ ███ ▀█████████▀ ▀▀███▀▀▀ ▀███████████ 10 | ███ ███ ███ ███ ███ █▄ ███ 11 | ███ ███ ███ ███ ███ ███ ▄█ ███ 12 | ▄████▀ ▀█████▀ ▄████▀ ██████████ ▄████████▀ 13 | 14 | */ 15 | 16 | /** 17 | * The state objects managed by Statery stores are any string-indexed JavaScript objects. 18 | */ 19 | export interface IState extends Record {} 20 | 21 | /** 22 | * Statery stores wrap around a State object and provide a few functions to update them 23 | * and, in turn, subscribe to updates. 24 | */ 25 | export type Store> = { 26 | /** 27 | * Return the current state. 28 | */ 29 | state: Readonly 30 | 31 | /** 32 | * Updates the store. Accepts an object that will be (shallow-)merged into the current state, 33 | * or a callback that will be invoked with the current state and is expected to return an object 34 | * containing updates. 35 | * 36 | * Returns the updated version of the store. 37 | * 38 | * @example 39 | * store.set({ foo: 1 }) 40 | * 41 | * @example 42 | * store.set(state => ({ foo: state.foo + 1})) 43 | * 44 | * @see StateUpdateFunction 45 | */ 46 | set: (updates: Partial | StateUpdateFunction, options?: SetOptions) => T 47 | 48 | /** 49 | * Subscribe to changes to the store's state. Every time the store is updated, the provided 50 | * listener callback will be invoked, with the object containing the updates passed as the 51 | * first argument, and the previous state as the second. 52 | * 53 | * @see Listener 54 | */ 55 | subscribe: (listener: Listener) => void 56 | 57 | /** 58 | * Unsubscribe a listener from being invoked when the the store changes. 59 | */ 60 | unsubscribe: (listener: Listener) => void 61 | } 62 | 63 | export type StateUpdateFunction = (state: Readonly) => Partial 64 | 65 | /** 66 | * Options for the `set` function. 67 | */ 68 | export type SetOptions = { forceNotify?: boolean } 69 | 70 | /** 71 | * A callback that can be passed to a store's `subscribe` and `unsubscribe` functions. 72 | */ 73 | export type Listener = (updates: Readonly>, state: Readonly) => void 74 | 75 | /* 76 | 77 | ▄████████ ███ ▄██████▄ ▄████████ ▄████████ 78 | ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ ███ 79 | ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ █▀ 80 | ███ ███ ▀ ███ ███ ▄███▄▄▄▄██▀ ▄███▄▄▄ 81 | ▀███████████ ███ ███ ███ ▀▀███▀▀▀▀▀ ▀▀███▀▀▀ 82 | ███ ███ ███ ███ ▀███████████ ███ █▄ 83 | ▄█ ███ ███ ███ ███ ███ ███ ███ ███ 84 | ▄████████▀ ▄████▀ ▀██████▀ ███ ███ ██████████ 85 | ███ ███ 86 | */ 87 | 88 | /** 89 | * Creates a Statery store and populates it with an initial state. 90 | * 91 | * @param initialState The state object that will be wrapped by the store. 92 | */ 93 | export const makeStore = (initialState: T): Store => { 94 | let state = initialState 95 | const listeners = new Set>() 96 | 97 | const getActualChanges = (updates: Partial) => 98 | Object.keys(updates).reduce>((changes, prop: keyof Partial) => { 99 | if (updates[prop] !== state[prop]) changes[prop] = updates[prop] 100 | return changes 101 | }, {}) 102 | 103 | return { 104 | get state() { 105 | return state 106 | }, 107 | 108 | set: (incoming, { forceNotify = false }: SetOptions = {}) => { 109 | /* If the argument is a function, run it */ 110 | const incomingState = incoming instanceof Function ? incoming(state) : incoming 111 | 112 | /* 113 | Check which updates we're actually applying. If forceNotify is enabled, 114 | we'll use (and notify for) all of them; otherwise, we'll check them against 115 | the current state to only change (and notify for) the properties 116 | that have changed from the current state. 117 | */ 118 | const updates = forceNotify ? incomingState : getActualChanges(incomingState) 119 | 120 | /* Has anything changed? */ 121 | if (Object.keys(updates).length > 0) { 122 | /* Keep a reference to the previous state, we're going to need it in a second */ 123 | const previousState = state 124 | 125 | /* Apply updates */ 126 | state = { ...state, ...updates } 127 | 128 | /* Execute listeners */ 129 | for (const listener of listeners) listener(updates, previousState) 130 | } 131 | 132 | return state 133 | }, 134 | 135 | subscribe: (listener) => { 136 | listeners.add(listener) 137 | }, 138 | 139 | unsubscribe: (listener) => { 140 | listeners.delete(listener) 141 | } 142 | } 143 | } 144 | 145 | /* 146 | 147 | ▄█ █▄ ▄██████▄ ▄██████▄ ▄█ ▄█▄ ▄████████ 148 | ███ ███ ███ ███ ███ ███ ███ ▄███▀ ███ ███ 149 | ███ ███ ███ ███ ███ ███ ███▐██▀ ███ █▀ 150 | ▄███▄▄▄▄███▄▄ ███ ███ ███ ███ ▄█████▀ ███ 151 | ▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ▀▀█████▄ ▀███████████ 152 | ███ ███ ███ ███ ███ ███ ███▐██▄ ███ 153 | ███ ███ ███ ███ ███ ███ ███ ▀███▄ ▄█ ███ 154 | ███ █▀ ▀██████▀ ▀██████▀ ███ ▀█▀ ▄████████▀ 155 | ▀ 156 | */ 157 | 158 | /** 159 | * Provides reactive read access to a Statery store. Returns a proxy object that 160 | * provides direct access to the store's state and makes sure that the React component 161 | * it was invoked from automatically re-renders when any of the data it uses is updated. 162 | * 163 | * @param store The Statery store to access. 164 | */ 165 | export const useStore = (store: Store): T => { 166 | /* A set containing all props that we're interested in. */ 167 | const subscribedProps = useConst(() => new Set()) 168 | const prevSnapshot = useRef(store.state) 169 | 170 | const subscribe = useCallback( 171 | (listener: () => void) => { 172 | store.subscribe(listener) 173 | return () => store.unsubscribe(listener) 174 | }, 175 | [store] 176 | ) 177 | 178 | const getSnapshot = useCallback(() => { 179 | let hasChanged = false 180 | 181 | for (const prop of subscribedProps) { 182 | if (store.state[prop] !== prevSnapshot.current[prop]) { 183 | hasChanged = true 184 | break 185 | } 186 | } 187 | 188 | if (hasChanged) { 189 | prevSnapshot.current = store.state 190 | } 191 | 192 | return prevSnapshot.current 193 | }, [store]) 194 | 195 | /* Pass getSnapshot to the "getServerSnapshot" argument to prevent SSR crash in Next.js */ 196 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) 197 | 198 | return new Proxy>( 199 | {}, 200 | { 201 | get: (_, prop: string) => { 202 | /* Add the prop we're interested in to the list of props */ 203 | subscribedProps.add(prop) 204 | 205 | /* Return the current value of the property. */ 206 | return snapshot[prop] 207 | } 208 | } 209 | ) 210 | } 211 | 212 | /** 213 | * A tiny helper hook that will initialize a ref with the return value of the 214 | * given constructor function. 215 | */ 216 | const useConst = (ctor: () => T) => { 217 | const ref = useRef(null!) 218 | if (!ref.current) ref.current = ctor() 219 | return ref.current 220 | } 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 💥 ⭐️ 💥 ✨ 3 | ✨ ✨ 4 | ███████╗████████╗ █████╗ ████████╗███████╗██████╗ ██╗ ██╗██╗ ✨ 5 | ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗╚██╗ ██╔╝██║ 6 | ███████╗ ██║ ███████║ ██║ █████╗ ██████╔╝ ╚████╔╝ ██║ ⭐️ ✨ 7 | ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ██╔══██╗ ╚██╔╝ ╚═╝ 8 | ███████║ ██║ ██║ ██║ ██║ ███████╗██║ ██║ ██║ ██╗ ✨ 💥 9 | ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ✨ 10 | ✨ ✨ ⭐️ 11 | SURPRISE-FREE STATE MANAGEMENT! 💥 12 | ✨ 13 | ``` 14 | 15 | [![Version](https://img.shields.io/npm/v/statery?style=for-the-badge)](https://www.npmjs.com/package/statery) 16 | ![CI](https://img.shields.io/github/actions/workflow/status/hmans/statery/tests.yml?style=for-the-badge) 17 | [![Downloads](https://img.shields.io/npm/dt/statery.svg?style=for-the-badge)](https://www.npmjs.com/package/statery) 18 | [![Bundle Size](https://img.shields.io/bundlephobia/min/statery?label=bundle%20size&style=for-the-badge)](https://bundlephobia.com/result?p=statery) 19 | 20 | ### Features 🎉 21 | 22 | - Simple, **noise- and surprise-free API**. Check out the [demo]! 23 | - **Extremely compact**, both in bundle size as well as API surface (2 exported functions!) 24 | - Fully **tested**, fully **typed**! 25 | - **Designed for React** (with functional components and hooks), but can also be used **without it**, or with other frameworks (but you may need to bring your own glue.) 26 | 27 | ### Non-Features 🧤 28 | 29 | - Doesn't use **React Context** (but you can easily use it to provide a context-specific store!) 30 | - Provides a simple `set` function for updating a store and not much else (have you checked out the [demo] yet?) If you want to use **reducers** or libraries like [Immer], these can easily sit on top of your Statery store. 31 | - Currently no support for (or concept of) **middlewares**, but this may change in the future. 32 | - While the `useStore` hook makes use of **proxies**, the store contents themselves are never wrapped in proxy objects. (If you're looking for a fully proxy-based solution, I recommend [Valtio].) 33 | - **React Class Components** are not supported (but PRs welcome!) 34 | 35 | ## SUMMARY 36 | 37 | ```tsx 38 | import { makeStore, useStore } from "statery" 39 | 40 | const store = makeStore({ counter: 0 }) 41 | 42 | const increment = () => 43 | store.set((state) => ({ 44 | counter: state.counter + 1 45 | })) 46 | 47 | const Counter = () => { 48 | const { counter } = useStore(store) 49 | 50 | return ( 51 |
52 |

Counter: {counter}

53 | 54 |
55 | ) 56 | } 57 | ``` 58 | 59 | For a more fully-featured example, please check out the [demo]. 60 | 61 | ## BASIC USAGE 62 | 63 | ### Adding it to your Project 64 | 65 | ``` 66 | npm add statery 67 | yarn add statery 68 | pnpm add statery 69 | ``` 70 | 71 | ### Creating a Store 72 | 73 | Statery stores wrap around plain old JavaScript objects that are free to contain any kind of data: 74 | 75 | ```ts 76 | import { value makeStore } from "statery" 77 | 78 | const store = makeStore({ 79 | player: { 80 | id: 1, 81 | name: "John Doe", 82 | email: "john@doe.com" 83 | }, 84 | gold: 100, 85 | wood: 0, 86 | houses: 0 87 | }) 88 | ``` 89 | 90 | If you're using TypeScript, the type of the store state will be inferred from the initial state; but you may also pass a type argument to `makeStore` to explicitly set the type of the store: 91 | 92 | ```ts 93 | const store = makeStore<{ count: number }>({ count: 0 }) 94 | ``` 95 | 96 | ### Updating the Store 97 | 98 | The store object's `set` function will update the store's state and notify any listeners who have subscribed to changes: 99 | 100 | ```tsx 101 | const collectWood = () => 102 | store.set((state) => ({ 103 | wood: state.wood + 1 104 | })) 105 | 106 | const buildHouse = () => 107 | store.set((state) => ({ 108 | wood: state.wood - 10, 109 | houses: state.houses + 1 110 | })) 111 | 112 | const Buttons = () => { 113 | return ( 114 |

115 | 116 | 117 |

118 | ) 119 | } 120 | ``` 121 | 122 | Updates will be shallow-merged with the current state, meaning that top-level properties will be replaced, and properties you don't update will not be touched. 123 | 124 | ### Reading from a Store (with React) 125 | 126 | Within a React component, use the `useStore` hook to read data from the store: 127 | 128 | ```tsx 129 | import { value useStore } from "statery" 130 | 131 | const Wood = () => { 132 | const { wood } = useStore(store) 133 | 134 | return

Wood: {wood}

135 | } 136 | ``` 137 | 138 | When any of the store's properties that your component accesses are updated, they will automatically re-render, automatically receiving the new state. 139 | 140 | ### Reading from a Store (without React) 141 | 142 | A Statery store provides access to its current state through its `state` property: 143 | 144 | ```ts 145 | const store = makeStore({ count: 0 }) 146 | console.log(store.state.count) 147 | ``` 148 | 149 | You can also imperatively [subscribe to updates](#subscribing-to-updates-imperatively). 150 | 151 | ## ADVANCED USAGE 152 | 153 | ### Deriving Values from a Store 154 | 155 | Just like mutations, functions that derive values from the store's state can be written as standalone functions: 156 | 157 | ```tsx 158 | const canBuyHouse = ({ wood, gold }) => wood >= 5 && gold >= 5 159 | ``` 160 | 161 | These will work from within plain imperative JavaScript code... 162 | 163 | ```tsx 164 | if (canBuyHouse(store.state)) { 165 | console.log("Let's buy a house!") 166 | } 167 | ``` 168 | 169 | ...mutation code... 170 | 171 | ```tsx 172 | const buyHouse = () => 173 | store.set((state) => 174 | canBuyHouse(state) 175 | ? { 176 | wood: state.wood - 5, 177 | gold: state.gold - 5, 178 | houses: state.houses + 1 179 | } 180 | : {} 181 | ) 182 | ``` 183 | 184 | ...as well as React components, which will automatically be re-rendered if any of the underlying data changes: 185 | 186 | ```tsx 187 | const BuyHouseButton = () => { 188 | const store = useStore(store) 189 | 190 | return ( 191 | 194 | ) 195 | } 196 | ``` 197 | 198 | ### Forcing a store update 199 | 200 | When the store is updated, Statery will check which of the properties within the update object are actually different objects (or scalar values) from the previous state, and will only notify listeners to those properties. 201 | 202 | In some cases, you may want to force a store update even though the property has not changed to a new object. For these situations, the `set` function allows you to pass a second argument; if this is set to `true`, Statery will ignore the equality check and notify all listeners to the properties included in the update. 203 | 204 | Example: 205 | 206 | ```tsx 207 | const store = makeStore({ 208 | rotation: new THREE.Vector3() 209 | }) 210 | 211 | export const randomizeRotation = () => 212 | store.set( 213 | (state) => ({ 214 | rotation: state.rotation.randomRotation() 215 | }), 216 | true 217 | ) 218 | ``` 219 | 220 | ### Subscribing to updates (imperatively) 221 | 222 | Use a store's `subscribe` function to register a callback that will be executed every time the store is changed. 223 | The callback will receive both an object containing of the changes, as well as the store's current state. 224 | 225 | ```ts 226 | const store = makeStore({ count: 0 }) 227 | 228 | const listener = (changes, state) => { 229 | console.log("Applying changes:", changes) 230 | } 231 | 232 | store.subscribe(console.log) 233 | store.set((state) => ({ count: state.count + 1 })) 234 | store.unsubscribe(console.log) 235 | ``` 236 | 237 | ## NOTES 238 | 239 | ### Stuff that probably needs work 240 | 241 | - [ ] No support for middleware yet. Haven't decided on an API that is adequately simple. 242 | 243 | ### Prior Art & Credits 244 | 245 | Statery was born after spending a lot of time with the excellent state management libraries provided by the [Poimandres](https://github.com/pmndrs) collective, [Zustand] and [Valtio]. Statery started out as an almost-clone of Zustand, but with the aim of providing an even simpler API. The `useStore` hook API was inspired by Valtio's very nice `useProxy`. 246 | 247 | Statery is written and maintained by [Hendrik Mans](https://www.hmans.dev/). 248 | 249 | [demo]: https://codesandbox.io/s/statery-clicker-game-hjxk3?file=/src/App.tsx 250 | [zustand]: https://github.com/pmndrs/zustand 251 | [valtio]: https://github.com/pmndrs/valtio 252 | [immer]: https://github.com/immerjs/immer 253 | --------------------------------------------------------------------------------