├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── babel.config.js ├── dist ├── index.d.ts └── index.js ├── package.json ├── src └── index.ts ├── tests ├── Counter.test.tsx ├── Counter.tsx ├── CounterState.tsx ├── CounterSubscriber.test.tsx ├── ProductState.ts ├── StateRacing.mocha.tsx ├── StateRacing.test.tsx ├── reset.test.tsx ├── selector.test.tsx ├── selectorIssue5.test.tsx ├── subscribe.test.tsx └── tsconfig.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | tests/StateRacing.mocha.tsx 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": ["./tsconfig.json", "./tests/tsconfig.json"], 5 | "sourceType": "module" 6 | }, 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | } 11 | }, 12 | "extends": [ 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 16 | "plugin:react-hooks/recommended", 17 | "plugin:react/recommended", 18 | "prettier" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16.x' 17 | cache: 'yarn' 18 | - run: yarn --frozen-lockfile 19 | - run: yarn build 20 | lint: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-node@v2 25 | with: 26 | node-version: '16.x' 27 | cache: 'yarn' 28 | - run: yarn --frozen-lockfile 29 | - run: yarn lint 30 | - run: yarn prettier --check src 31 | test: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-node@v2 36 | with: 37 | node-version: '16.x' 38 | cache: 'yarn' 39 | - run: yarn --frozen-lockfile 40 | - run: yarn test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .env 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | tests 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at info@webridge.nl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 WebRidge Design 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-ridge-state :weight_lifting_woman: ⚡️ :weight_lifting_man: 2 | 3 | ![Bundle Size](https://badgen.net/bundlephobia/minzip/react-ridge-state) [![npm version](https://badge.fury.io/js/react-ridge-state.svg)](https://badge.fury.io/js/react-ridge-state) ![npm](https://img.shields.io/npm/dt/react-ridge-state.svg) 4 | 5 | **Simple** :muscle: **fast** ⚡️ and **small** :balloon: (400 bytes) global state management for React which can be used outside of a React component too! 6 | 7 | ``` 8 | yarn add react-ridge-state 9 | ``` 10 | 11 | or 12 | 13 | ``` 14 | npm install react-ridge-state --save 15 | ``` 16 | 17 | ## Why another state library :thinking: 18 | 19 | We were frustrated that the current solutions could often only be used from React or have too complicated APIs. We wanted a lightweight solution with a smart API that can also be used outside React components. 20 | 21 | ## Features :woman_juggling: 22 | 23 | - React / React Native 24 | - Simple 25 | - Fast 26 | - Very tiny (400 bytes) 27 | - 100% Typesafe 28 | - Hooks 29 | - Use outside React components 30 | - Custom selectors for deep state selecting 31 | 32 | ## About us 33 | We want developers to be able to build software faster using modern tools like GraphQL, Golang and React Native. 34 | 35 | Give us a follow on Twitter: 36 | [RichardLindhout](https://twitter.com/RichardLindhout), 37 | [web_ridge](https://twitter.com/web_ridge) 38 | 39 | ## Donate 40 | Please contribute or donate so we can spend more time on this library 41 | 42 | [Donate with PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=7B9KKQLXTEW9Q&source=url) 43 | 44 | 45 | ## Getting started :clap: :ok_hand: 46 | 47 | ### Create a new state 48 | 49 | ```typescript 50 | import { newRidgeState } from "react-ridge-state"; 51 | 52 | interface CartProduct { 53 | id: number; 54 | name: string; 55 | } 56 | 57 | export const cartProductsState = newRidgeState([ 58 | { id: 1, name: "Product" }, 59 | ]); 60 | ``` 61 | 62 | ### Use state inside components 63 | 64 | ```typescript 65 | import { cartProductsState } from "../cartProductsState"; 66 | 67 | // same interface and usage as setState 68 | const [cartProducts, setCartProducts] = cartProductsState.use(); 69 | 70 | // if you only need the value and no setState 71 | const cartProducts = cartProductsState.useValue(); 72 | 73 | // if you only want to subscribe to part of your state (this example the first product) 74 | const cartProducts = cartProductsState.useSelector((state) => state[0]); 75 | 76 | // custom comparison function (only use this if you have heavy child components and the default === comparison is not sufficient enough) 77 | const cartProducts = cartProductsState.useSelector( 78 | (state) => state[0], 79 | (a, b) => JSON.stringify(a) === JSON.stringify(b) 80 | ); 81 | ``` 82 | 83 | ### Supported functions outside of React 84 | The following functions work outside of React e.g. in your middleware but you can also use them in your component. 85 | 86 | ```typescript 87 | import { cartProductsState } from "../cartProductsState"; 88 | 89 | // get the root state 90 | cartProductsState.get(); 91 | 92 | // set the state directly 93 | cartProductsState.set([{ id: 1, name: "NiceProduct" }]); 94 | 95 | // if you want previous state as callback 96 | cartProductsState.set((prevState) => [ 97 | ...prevState, 98 | { id: 1, name: "NiceProduct" }, 99 | ]); 100 | 101 | // you can also use a callback so you know when state has rendered 102 | cartProductsState.set( 103 | (prevState) => [...prevState, { id: 1, name: "NiceProduct" }], 104 | (newState) => { 105 | console.log("New state is rendered everywhere"); 106 | } 107 | ); 108 | 109 | // you can reset to initial state too 110 | cartProductsState.reset() 111 | 112 | // you can also subscribe to state changes outside React 113 | 114 | const unsubscribe = cartProductsState.subscribe((newState, oldState) => { 115 | console.log("State changed"); 116 | }); 117 | 118 | // call the returned unsubscribe function to unsubscribe. 119 | unsubscribe(); 120 | ``` 121 | 122 | ### Example 123 | 124 | ```tsx 125 | // CartState.ts 126 | import { newRidgeState } from "react-ridge-state"; 127 | 128 | // this can be used everywhere in your application 129 | export const globalCounterState = newRidgeState(0); // 0 could be something else like objects etc. you decide! 130 | 131 | // Counter.tsx 132 | function Counter() { 133 | // you can use these everywhere in your application the globalCounterState will update automatically even if set globally 134 | const [count, setCount] = globalCounterState.use(); 135 | return ( 136 |
137 |
Count: {count}
138 | 139 |
140 | ); 141 | } 142 | 143 | // CounterViewer.tsx 144 | function CounterViewer() { 145 | // you can use these everywhere in your application the globalCounterState will update automatically even if set globally 146 | const counter = globalCounterState.useValue(); 147 | 148 | return ( 149 |
150 |
Count: {counter}
151 |
152 | ); 153 | } 154 | ``` 155 | 156 | ### Usage in class components 157 | 158 | Since we want to keep this library small we are not supporting class components but you could use wrappers like this if you have class components, however we would recommend to use functional components since they are more type safe and easier to use. 159 | 160 | ```tsx 161 | 162 | class YourComponentInternal extends Component { 163 | render() { 164 |
165 |
Count: {this.props.count}
166 | 167 |
168 | } 169 | } 170 | 171 | export default function YourComponent(props) { 172 | const [count, setCount] = globalCounterState.use(); 173 | return 174 | } 175 | ``` 176 | 177 | ### Persistence example 178 | 179 | It's possible to add make your state persistent, you can use storage library you desire. 180 | localStorage is even simpler since you don't need async functions 181 | 182 | ```typescript 183 | const authStorageKey = "auth"; 184 | const authState = newRidgeState( 185 | { loading: true, token: "" }, 186 | { 187 | onSet: async (newState) => { 188 | try { 189 | await AsyncStorage.setItem("@key", JSON.stringify(newState)); 190 | } catch (e) {} 191 | }, 192 | } 193 | ); 194 | 195 | // setInitialState fetches data from localStorage 196 | async function setInitialState() { 197 | try { 198 | const item = await AsyncStorage.getItem("@key"); 199 | if (item) { 200 | const initialState = JSON.parse(item); 201 | authState.set(initialState); 202 | } 203 | } catch (e) {} 204 | } 205 | 206 | // run function as application starts 207 | setInitialState(); 208 | ``` 209 | 210 | ### Managing complex/nested state with Immer 211 | 212 | Sometimes you might need to update values that are deeply nested, code for this can end up looking verbose as you will likely need to use many spread operators. A small utility library called [Immer](https://github.com/immerjs/immer) can help simplify things. 213 | 214 | ```tsx 215 | const characterState = newRidgeState({ 216 | gold: 100, 217 | stats: { 218 | spells: { 219 | fire: 10, 220 | watter: 10 221 | }, 222 | battle: { 223 | health: 100, 224 | mana: 100 225 | }, 226 | profession: { 227 | mining: 10, 228 | herbalism: 10 229 | } 230 | } 231 | }) 232 | 233 | // Update mana and herbalism without immer 234 | characterState.set(previous => ({ 235 | ...previous, 236 | stats: { 237 | ...previous.stats, 238 | battle: { 239 | ...previous.stats.battle, 240 | mana: 200 241 | }, 242 | profession: { 243 | ...previous.stats.profession, 244 | herbalism: 20 245 | } 246 | } 247 | })) 248 | 249 | // Update mana and herbalism using immer 250 | import { produce } from "immer"; 251 | 252 | characterState.set(previous => 253 | produce(previous, updated => { 254 | updated.stats.battle.mana = 200 255 | updated.stats.profession.herbalism = 20 256 | }) 257 | ) 258 | ``` 259 | 260 | 261 | ## Testing your components which use react-ridge-state 262 | 263 | You can find examples of testing components with global state here: 264 | https://github.com/web-ridge/react-ridge-state/blob/main/src/tests/Counter.test.tsx 265 | 266 | ### Jest 267 | Jest keeps the global state between tests in one file. 268 | Tests inside one file run synchronous by default, so no racing can occur. 269 | 270 | When testing in different files (test1.test.js, test2.test.js), the global state is new for every file. 271 | You don't have to mock or reset the state even if the tests run in parallel. 272 | 273 | ### Mocha 274 | 275 | In Mocha you will need to reset the state the initial value before each test since the state is shared across all tests. 276 | You could do that with the code below and **not** using the --parallel mode of Mocha. 277 | 278 | ```tsx 279 | beforeEach(()=> { 280 | characterState.reset() 281 | }) 282 | ``` 283 | 284 | ### Checkout our other libraries 285 | - Simple form library for React Native with great UX for developer and end-user [react-native-use-form](https://github.com/web-ridge/react-native-use-form) 286 | - Smooth and fast cross platform Material Design date and time picker for React Native Paper: [react-native-paper-dates](https://github.com/web-ridge/react-native-paper-dates) 287 | - Smooth and fast cross platform Material Design Tabs for React Native Paper: [react-native-paper-tabs](https://github.com/web-ridge/react-native-paper-tabs) 288 | - Simple translations in React (Native): [react-ridge-translations](https://github.com/web-ridge/react-ridge-translations) 289 | - 1 command utility for React Native (Web) project: [create-react-native-web-application](https://github.com/web-ridge/create-react-native-web-application) 290 | 291 | 292 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | env: { 4 | test: { 5 | plugins: ["@babel/plugin-transform-runtime"], 6 | }, 7 | }, 8 | presets: [ 9 | "@babel/preset-env", 10 | "@babel/preset-react", 11 | "@babel/preset-typescript", 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction } from "react"; 2 | declare type Set = (newState: SetStateAction, callback?: (newState: T) => void) => void; 3 | declare type UseSelector = (selector: (state: T) => TSelected, equalityFn?: Comparator) => TSelected; 4 | export interface StateWithValue { 5 | use: () => [T, Set]; 6 | useValue: () => T; 7 | get: () => T; 8 | useSelector: UseSelector; 9 | set: Set; 10 | reset: () => void; 11 | subscribe(subscriber: SubscriberFunc): () => void; 12 | } 13 | declare type SubscriberFunc = (newState: T, previousState: T) => void; 14 | interface Options { 15 | onSet?: SubscriberFunc; 16 | } 17 | declare type Comparator = (a: TSelected, b: TSelected) => boolean; 18 | export declare function newRidgeState(initialValue: T, options?: Options): StateWithValue; 19 | export {}; 20 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.newRidgeState = void 0; 4 | const react_1 = require("react"); 5 | const useIsomorphicLayoutEffect = typeof window !== "undefined" || typeof document !== "undefined" 6 | ? react_1.useLayoutEffect 7 | : react_1.useEffect; 8 | const equ = (a, b) => a === b; 9 | const FR = {}; 10 | function useComparator(v, c = equ) { 11 | const f = (0, react_1.useRef)(FR); 12 | let nv = f.current; 13 | useIsomorphicLayoutEffect(() => { 14 | f.current = nv; 15 | }); 16 | if (f.current === FR || !c(v, f.current)) { 17 | nv = v; 18 | } 19 | return nv; 20 | } 21 | function newRidgeState(initialValue, options) { 22 | let sb = []; 23 | let v = initialValue; 24 | function set(newValue, callback) { 25 | const pv = v; 26 | v = newValue instanceof Function ? newValue(v) : newValue; 27 | setTimeout(() => { 28 | var _a; 29 | sb.forEach((c) => c(v, pv)); 30 | callback === null || callback === void 0 ? void 0 : callback(v, pv); 31 | (_a = options === null || options === void 0 ? void 0 : options.onSet) === null || _a === void 0 ? void 0 : _a.call(options, v, pv); 32 | }); 33 | } 34 | function subscribe(subscriber) { 35 | sb.push(subscriber); 36 | return () => { 37 | sb = sb.filter((f) => f !== subscriber); 38 | }; 39 | } 40 | function useSubscription(subscriber) { 41 | useIsomorphicLayoutEffect(() => subscribe(subscriber), [subscriber]); 42 | } 43 | function use() { 44 | const [l, s] = (0, react_1.useState)(v); 45 | useSubscription(s); 46 | return [l, set]; 47 | } 48 | function useSelector(selector, comparator = equ) { 49 | const [rv] = use(); 50 | return useComparator(selector(rv), comparator); 51 | } 52 | return { 53 | use, 54 | useSelector, 55 | useValue: () => use()[0], 56 | get: () => v, 57 | set, 58 | reset: () => set(initialValue), 59 | subscribe, 60 | }; 61 | } 62 | exports.newRidgeState = newRidgeState; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ridge-state", 3 | "version": "4.2.9", 4 | "description": "react-ridge-state is a very simple global state management library for React and React Native", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "homepage": "https://github.com/web-ridge/react-ridge-state", 8 | "license": "MIT", 9 | "readme": "README.md", 10 | "scripts": { 11 | "mocha": "mocha -r jsdom-global/register -r ts-node/register tests/**/*.mocha.tsx", 12 | "test": "jest --maxWorkers=150", 13 | "build": "tsc", 14 | "lint": "eslint src tests", 15 | "minify": "uglifyjs --compress --mangle --output dist/index.js -- dist/index.js" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.16.0", 19 | "@babel/plugin-transform-runtime": "^7.16.0", 20 | "@babel/preset-env": "^7.16.0", 21 | "@babel/preset-react": "^7.16.0", 22 | "@babel/preset-typescript": "^7.16.0", 23 | "@testing-library/react": "^12.1.2", 24 | "@types/jest": "^27.0.2", 25 | "@types/node": "^16.11.6", 26 | "@types/react": "^17.0.34", 27 | "@types/react-dom": "^17.0.11", 28 | "@types/react-test-renderer": "^17.0.1", 29 | "@typescript-eslint/eslint-plugin": "^5.3.0", 30 | "@typescript-eslint/parser": "^5.3.0", 31 | "babel-jest": "^27.3.1", 32 | "esbuild": "^0.13.12", 33 | "eslint": "^7.21.0", 34 | "eslint-config-prettier": "^8.3.0", 35 | "eslint-plugin-react": "^7.26.1", 36 | "eslint-plugin-react-hooks": "^4.2.0", 37 | "expect.js": "^0.3.1", 38 | "jest": "^27.3.1", 39 | "jsdom": "^18.0.1", 40 | "jsdom-global": "^3.0.2", 41 | "mocha": "^9.1.3", 42 | "mocha-jsdom": "^2.0.0", 43 | "prettier": "^2.4.1", 44 | "react": "^17.0.2", 45 | "react-dom": "^17.0.2", 46 | "react-test-renderer": "^17.0.2", 47 | "ts-node": "^10.4.0", 48 | "typescript": "^4.4.4", 49 | "uglify-js": "^3.15.1", 50 | "uglifyjs": "^2.4.11" 51 | }, 52 | "peerDependencies": { 53 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 54 | }, 55 | "jest": { 56 | "testEnvironment": "jsdom" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useRef, 4 | useLayoutEffect, 5 | useEffect, 6 | SetStateAction, 7 | } from "react"; 8 | 9 | type Set = ( 10 | newState: SetStateAction, // can be the newState or a function with prevState in params and which needs to return new state 11 | callback?: (newState: T) => void // callback with the newState after state has been set 12 | ) => void; 13 | 14 | type UseSelector = ( 15 | selector: (state: T) => TSelected, 16 | equalityFn?: Comparator 17 | ) => TSelected; 18 | 19 | export interface StateWithValue { 20 | use: () => [T, Set]; 21 | useValue: () => T; 22 | get: () => T; 23 | useSelector: UseSelector; 24 | set: Set; 25 | reset: () => void; 26 | subscribe(subscriber: SubscriberFunc): () => void; 27 | } 28 | 29 | type SubscriberFunc = (newState: T, previousState: T) => void; 30 | 31 | interface Options { 32 | onSet?: SubscriberFunc; 33 | } 34 | 35 | type Comparator = (a: TSelected, b: TSelected) => boolean; 36 | 37 | // React currently throws a warning when using useLayoutEffect on the server. 38 | // To get around it, we can conditionally useEffect on the server (no-op) and 39 | // useLayoutEffect in the browser. We need useLayoutEffect to ensure the store 40 | // subscription callback always has the selector from the latest render commit 41 | // available, otherwise a store update may happen between render and the effect, 42 | // which may cause missed updates; we also must ensure the store subscription 43 | // is created synchronously, otherwise a store update may occur before the 44 | // subscription is created and an inconsistent state may be observed 45 | 46 | const useIsomorphicLayoutEffect = 47 | typeof window !== "undefined" || typeof document !== "undefined" 48 | ? useLayoutEffect 49 | : useEffect; 50 | 51 | const equ: Comparator = (a, b) => a === b; 52 | 53 | const FR = {}; // an opaque value 54 | function useComparator(v: T, c: Comparator = equ): T { 55 | const f = useRef(FR as T); 56 | let nv = f.current; 57 | 58 | useIsomorphicLayoutEffect(() => { 59 | f.current = nv; 60 | }); 61 | 62 | if (f.current === FR || !c(v, f.current)) { 63 | nv = v; 64 | } 65 | 66 | return nv; 67 | } 68 | 69 | export function newRidgeState( 70 | initialValue: T, 71 | options?: Options 72 | ): StateWithValue { 73 | // subscribers with callbacks for external updates 74 | let sb: SubscriberFunc[] = []; 75 | 76 | // internal value of the state 77 | let v: T = initialValue; 78 | 79 | // set function 80 | function set(newValue: SetStateAction, callback?: SubscriberFunc) { 81 | const pv = v; 82 | // support previous as argument to new value 83 | v = newValue instanceof Function ? newValue(v) : newValue; 84 | 85 | // let subscribers know value did change async 86 | setTimeout(() => { 87 | // call subscribers 88 | sb.forEach((c) => c(v, pv)); 89 | 90 | // callback after state is set 91 | callback?.(v, pv); 92 | 93 | // let options function know when state has been set 94 | options?.onSet?.(v, pv); 95 | }); 96 | } 97 | 98 | // subscribe function; returns unsubscriber function 99 | function subscribe(subscriber: SubscriberFunc): () => void { 100 | sb.push(subscriber); 101 | return () => { 102 | sb = sb.filter((f) => f !== subscriber); 103 | }; 104 | } 105 | 106 | // subscribe hook 107 | function useSubscription(subscriber: SubscriberFunc) { 108 | // subscribe effect 109 | useIsomorphicLayoutEffect(() => subscribe(subscriber), [subscriber]); 110 | } 111 | 112 | // use hook 113 | function use(): [T, Set] { 114 | // eslint-disable-next-line react-hooks/rules-of-hooks 115 | const [l, s] = useState(v); 116 | 117 | // subscribe to external changes 118 | // eslint-disable-next-line react-hooks/rules-of-hooks 119 | useSubscription(s); 120 | 121 | // set callback 122 | return [l, set]; 123 | } 124 | 125 | // select hook 126 | function useSelector( 127 | selector: (state: T) => TSelected, 128 | comparator: Comparator = equ 129 | ): TSelected { 130 | const [rv] = use(); 131 | return useComparator(selector(rv), comparator); 132 | } 133 | 134 | return { 135 | use, 136 | useSelector, 137 | useValue: () => use()[0], 138 | get: () => v, 139 | set, 140 | reset: () => set(initialValue), 141 | subscribe, 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /tests/Counter.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | fireEvent, 3 | getNodeText, 4 | render, 5 | waitFor, 6 | } from "@testing-library/react"; 7 | import { CounterComponent, CounterViewer } from "./Counter"; 8 | import * as React from "react"; 9 | import { act } from "react-dom/test-utils"; 10 | 11 | test("Both counters and global state change after click and global +", async () => { 12 | const counters = render( 13 | <> 14 | 15 | ) 16 | 17 | ); 18 | 19 | act(() => { 20 | fireEvent.click(counters.queryByTestId("counterButton")); 21 | }); 22 | 23 | const getCounterValueFromDiv = (testId: string): number => { 24 | return Number(getNodeText(counters.queryByTestId(testId))); 25 | }; 26 | 27 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(1)); 28 | await waitFor(() => expect(getCounterValueFromDiv("cv2")).toBe(1)); 29 | 30 | act(() => { 31 | fireEvent.click(counters.queryByTestId("counterButton")); 32 | }); 33 | 34 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(2)); 35 | await waitFor(() => expect(getCounterValueFromDiv("cv2")).toBe(2)); 36 | 37 | // test global get/set 38 | act(() => { 39 | fireEvent.click(counters.queryByTestId("counterButton")); 40 | }); 41 | 42 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(3)); 43 | await waitFor(() => expect(getCounterValueFromDiv("cv2")).toBe(3)); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/Counter.tsx: -------------------------------------------------------------------------------- 1 | // this can be used everywhere in your application 2 | 3 | import * as React from "react"; 4 | import { globalCounterState } from "./CounterState"; 5 | 6 | export function CounterComponent() { 7 | const [count, setCount] = globalCounterState.use(); 8 | return ( 9 | <> 10 |

{count}

11 | 17 | 18 | ); 19 | } 20 | 21 | // you can use these everywhere in your application the globalCounterState will update automatically 22 | // even if set globally 23 | export function CounterViewer() { 24 | const counter = globalCounterState.useValue(); 25 | 26 | return

{counter}

; 27 | } 28 | -------------------------------------------------------------------------------- /tests/CounterState.tsx: -------------------------------------------------------------------------------- 1 | import { newRidgeState } from "../src"; 2 | 3 | export const globalCounterState = newRidgeState(0, { 4 | onSet: (newState) => { 5 | try { 6 | localStorage.setItem("@key", JSON.stringify(newState)); 7 | } catch (e) {} 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /tests/CounterSubscriber.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | act, 3 | fireEvent, 4 | getNodeText, 5 | render, 6 | waitFor, 7 | } from "@testing-library/react"; 8 | import { CounterComponent, CounterViewer } from "./Counter"; 9 | import * as React from "react"; 10 | import { globalCounterState } from "./CounterState"; 11 | 12 | test("Test if unsubscribe works", async () => { 13 | // test react hooks 14 | const counter = render(); 15 | const counter2 = render(); 16 | 17 | act(() => { 18 | fireEvent.click(counter.queryByTestId("counterButton")); 19 | }); 20 | 21 | const getCounterValueFromDiv = (testId: string): number => { 22 | return Number(getNodeText(counter.queryByTestId(testId))); 23 | }; 24 | 25 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(1)); 26 | await waitFor(() => expect(getCounterValueFromDiv("cv2")).toBe(1)); 27 | 28 | // test global state set with previous callback 29 | act(() => { 30 | globalCounterState.set((prev) => prev + 1); 31 | }); 32 | 33 | counter2.unmount(); 34 | 35 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(2)); 36 | 37 | // test global get/set 38 | const currentGlobalValue = globalCounterState.get(); 39 | act(() => { 40 | globalCounterState.set(currentGlobalValue + 1); 41 | }); 42 | await waitFor(() => expect(getCounterValueFromDiv("cv1")).toBe(3)); 43 | 44 | // test if state is saved in persistent state 45 | await waitFor(() => expect(JSON.parse(localStorage.getItem("@key"))).toBe(3)); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/ProductState.ts: -------------------------------------------------------------------------------- 1 | import { newRidgeState } from "../src"; 2 | 3 | export interface Product { 4 | id: string; 5 | name: string; 6 | } 7 | 8 | export const defaultState = { 9 | id: "1", 10 | name: "Test", 11 | }; 12 | 13 | export function newProductState() { 14 | return newRidgeState(defaultState); 15 | } 16 | -------------------------------------------------------------------------------- /tests/StateRacing.mocha.tsx: -------------------------------------------------------------------------------- 1 | // This file was copied with 2 | // for i in {1..300}; do cp StateRacing.mocha.tsx "StateRacing_$i.mocha.tsx" ; done 3 | // to test if global state is shared between Jest files 4 | 5 | import { globalCounterState } from "./CounterState"; 6 | const expectMocha = require("expect.js"); 7 | 8 | it("Test if global state is not shared between files in Mocha", async () => { 9 | await sleeper(getRndInteger(1, 10)); 10 | expectMocha(globalCounterState.get()).to.be(0); 11 | globalCounterState.set((prev) => prev + 1); 12 | await sleeper(getRndInteger(1, 10)); 13 | expectMocha(globalCounterState.get()).to.be(1); 14 | globalCounterState.set((prev) => prev + 1); 15 | await sleeper(getRndInteger(1, 10)); 16 | expectMocha(globalCounterState.get()).to.be(2); 17 | globalCounterState.set((prev) => prev + 1); 18 | await sleeper(getRndInteger(1, 10)); 19 | expectMocha(globalCounterState.get()).to.be(3); 20 | globalCounterState.set((prev) => prev + 1); 21 | await sleeper(getRndInteger(1, 10)); 22 | expectMocha(globalCounterState.get()).to.be(4); 23 | globalCounterState.set((prev) => prev + 1); 24 | expectMocha(globalCounterState.get()).to.be(5); 25 | }); 26 | function getRndInteger(min, max) { 27 | return Math.floor(Math.random() * (max - min)) + min; 28 | } 29 | function sleeper(ms) { 30 | return function (x) { 31 | return new Promise((resolve) => setTimeout(() => resolve(x), ms)); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /tests/StateRacing.test.tsx: -------------------------------------------------------------------------------- 1 | // This file was copied with 2 | // for i in {1..300}; do cp StateRacing.test.tsx "StateRacing_$i.test.tsx" ; done 3 | // to test if global state is shared between Jest files 4 | 5 | import { globalCounterState } from "./CounterState"; 6 | 7 | test("Test if global state is not shared between files in Jest", async () => { 8 | await sleeper(getRndInteger(1, 10)); 9 | expect(globalCounterState.get()).toBe(0); 10 | globalCounterState.set((prev) => prev + 1); 11 | await sleeper(getRndInteger(1, 10)); 12 | expect(globalCounterState.get()).toBe(1); 13 | globalCounterState.set((prev) => prev + 1); 14 | await sleeper(getRndInteger(1, 10)); 15 | expect(globalCounterState.get()).toBe(2); 16 | globalCounterState.set((prev) => prev + 1); 17 | await sleeper(getRndInteger(1, 10)); 18 | expect(globalCounterState.get()).toBe(3); 19 | globalCounterState.set((prev) => prev + 1); 20 | await sleeper(getRndInteger(1, 10)); 21 | expect(globalCounterState.get()).toBe(4); 22 | globalCounterState.set((prev) => prev + 1); 23 | expect(globalCounterState.get()).toBe(5); 24 | }); 25 | 26 | function getRndInteger(min: number, max: number) { 27 | return Math.floor(Math.random() * (max - min)) + min; 28 | } 29 | 30 | function sleeper(ms: number) { 31 | return new Promise((resolve) => setTimeout(resolve, ms)); 32 | } 33 | -------------------------------------------------------------------------------- /tests/reset.test.tsx: -------------------------------------------------------------------------------- 1 | import { defaultState, newProductState } from "./ProductState"; 2 | 3 | test("Test if reset works", () => { 4 | const productState = newProductState(); 5 | const newState = { 6 | id: "2", 7 | name: "Test2", 8 | }; 9 | productState.set(newState); 10 | 11 | expect(productState.get()).not.toBe(defaultState); 12 | expect(productState.get()).toBe(newState); 13 | productState.reset(); 14 | expect(productState.get()).toBe(defaultState); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/selector.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { newRidgeState } from "../src"; 3 | import { render, waitFor } from "@testing-library/react"; 4 | 5 | interface DeepImage { 6 | url: string; 7 | } 8 | 9 | interface DeepMedia { 10 | name: string; 11 | imageVariations: DeepImage[]; 12 | } 13 | 14 | interface DeepProduct { 15 | name: string; 16 | price: number; 17 | media: DeepMedia[]; 18 | } 19 | 20 | interface DeepState { 21 | cart: { 22 | products: DeepProduct[]; 23 | total: number; 24 | }; 25 | } 26 | 27 | const deepState = newRidgeState({ 28 | cart: { 29 | products: [ 30 | { 31 | name: "Nice product", 32 | price: 100, 33 | media: [ 34 | { 35 | name: "Logo", 36 | imageVariations: [ 37 | { 38 | url: "https://webridge.nl/img/black-logo.png", 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | ], 45 | total: 100, 46 | }, 47 | }); 48 | 49 | let deepMediaRenders = 0; 50 | function DeepMedia() { 51 | deepMediaRenders++; 52 | const imageVariation = deepState.useSelector( 53 | (state) => state.cart.products[0].media[0].imageVariations[0] 54 | ); 55 | return ; 56 | } 57 | 58 | test("Select should not re-render when not changed", async () => { 59 | // test react hooks 60 | const deepMedia = render(); 61 | 62 | // test if render count = 1 63 | await waitFor(() => deepMediaRenders === 1); 64 | 65 | // should not render if media itself has not changed 66 | deepState.set((prev) => ({ 67 | ...prev, 68 | cart: { 69 | ...prev.cart, 70 | products: prev.cart.products.map((p) => ({ 71 | ...p, 72 | name: "NiceProductName", 73 | })), 74 | }, 75 | })); 76 | 77 | // should not have re-rendered since equal has not changed 78 | await waitFor(() => deepMediaRenders === 1); 79 | await waitFor(() => 80 | expect(deepMedia.queryByTestId("image")).toHaveProperty( 81 | "src", 82 | "https://webridge.nl/img/black-logo.png" 83 | ) 84 | ); 85 | 86 | // should render if media itself has changed 87 | deepState.set((prev) => ({ 88 | ...prev, 89 | cart: { 90 | ...prev.cart, 91 | products: prev.cart.products.map((p) => ({ 92 | ...p, 93 | name: "NiceProductName", 94 | media: p.media.map((m) => ({ 95 | ...m, 96 | imageVariations: m.imageVariations.map((iv) => ({ 97 | ...iv, 98 | url: "https://webridge.nl/favicon.png", 99 | })), 100 | })), 101 | })), 102 | }, 103 | })); 104 | 105 | // test if not called many times 106 | await waitFor(() => deepMediaRenders === 2); 107 | 108 | await waitFor(() => 109 | expect(deepMedia.queryByTestId("image")).toHaveProperty( 110 | "src", 111 | "https://webridge.nl/favicon.png" 112 | ) 113 | ); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/selectorIssue5.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { newRidgeState } from "../src"; 3 | import { getNodeText, render, waitFor } from "@testing-library/react"; 4 | 5 | export interface RouterState { 6 | page: "pageOne" | "pageTwo" | "pageThree"; 7 | } 8 | 9 | const { set, useSelector } = newRidgeState({ 10 | page: "pageOne", 11 | }); 12 | 13 | function push(page: RouterState["page"]) { 14 | set({ page }); 15 | } 16 | 17 | function Issue5Component() { 18 | const page = useSelector((s) => s.page); 19 | 20 | return ( 21 | <> 22 | 23 | {page === "pageOne" && "One"} 24 | {page === "pageTwo" && "Two"} 25 | {page === "pageThree" && "Three"} 26 | 27 | 28 | ); 29 | } 30 | 31 | test("Selector sucessfully re-renders on change", async () => { 32 | // test react hooks 33 | const issue5Component = render(); 34 | const getValue = (): string => { 35 | return getNodeText(issue5Component.queryByTestId("current-page")); 36 | }; 37 | await waitFor(() => expect(getValue()).toBe("One")); 38 | push("pageTwo"); 39 | await waitFor(() => expect(getValue()).toBe("Two")); 40 | push("pageThree"); 41 | await waitFor(() => expect(getValue()).toBe("Three")); 42 | push("pageOne"); 43 | await waitFor(() => expect(getValue()).toBe("One")); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/subscribe.test.tsx: -------------------------------------------------------------------------------- 1 | import { newProductState } from "./ProductState"; 2 | import { waitFor } from "@testing-library/react"; 3 | 4 | test("Test if subscribe works", async () => { 5 | const productState = newProductState(); 6 | const subscriber = jest.fn(); 7 | const unsub = productState.subscribe(subscriber); 8 | productState.set({ 9 | id: "2", 10 | name: "Test2", 11 | }); 12 | await waitFor(() => expect(subscriber).toHaveBeenCalledTimes(1)); 13 | unsub(); 14 | let done = false; 15 | productState.set( 16 | { 17 | id: "3", 18 | name: "Test3", 19 | }, 20 | () => { 21 | // this callback is called after subscribers 22 | expect(subscriber).toHaveBeenCalledTimes(1); 23 | done = true; 24 | } 25 | ); 26 | await waitFor(() => done); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "removeComments": true, 8 | "jsx": "react", 9 | "emitDeclarationOnly": false, 10 | "strict": true 11 | }, 12 | "include": ["src/*"] 13 | } 14 | --------------------------------------------------------------------------------