├── .babelrc ├── .gitignore ├── .eslintrc ├── tsconfig.json ├── .github └── workflows │ └── node.js.yml ├── LICENSE.md ├── package.json ├── src └── index.ts ├── __test__ └── index.test.ts └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | node_modules 3 | dist 4 | # Extensions 5 | *.log 6 | *.DS_Store -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-baron", 3 | "parser": "@babel/eslint-parser" 4 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "strict": true, 5 | "declaration": true, 6 | "sourceMap": true, 7 | "resolveJsonModule": true, 8 | "module": "CommonJS", 9 | "target": "es6", 10 | "alwaysStrict": true, 11 | "downlevelIteration": true, 12 | "lib": ["esnext", "dom"], 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": "." 16 | }, 17 | "include": [ 18 | "**/*.d.ts", 19 | "**/*.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "**/*.test.*" 24 | ] 25 | } -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm run verify 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Baron Willeford. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "galactic-state", 3 | "version": "2.0.2", 4 | "description": "Simplified global React state", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "lint": "eslint ./src/**", 9 | "test": "jest --watch", 10 | "clean": "rm -rf dist && mkdir dist", 11 | "build": "npm run clean && npm run build:types && npm run build:js", 12 | "build:types": "tsc --emitDeclarationOnly", 13 | "build:js": "babel src --out-dir dist --extensions \".ts,.txt\" --source-maps inline", 14 | "prepare": "npm run build", 15 | "ts": "tsc --noEmit", 16 | "verify": "npm run ts && npm run lint && jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/baron816/Galactic-State.git" 21 | }, 22 | "keywords": [ 23 | "React", 24 | "hooks" 25 | ], 26 | "author": "Baron Willeford", 27 | "license": "MIT", 28 | "peerDependencies": { 29 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.12.8", 33 | "@babel/core": "^7.12.9", 34 | "@babel/eslint-parser": "^7.12.1", 35 | "@babel/preset-env": "^7.12.7", 36 | "@babel/preset-typescript": "^7.12.7", 37 | "@testing-library/react-hooks": "^5.0.3", 38 | "@types/jest": "^26.0.16", 39 | "@types/react": "^17.0.0", 40 | "babel-jest": "^26.6.3", 41 | "eslint": "^7.17.0", 42 | "eslint-config-baron": "^1.0.0", 43 | "eslint-config-prettier": "^7.1.0", 44 | "eslint-plugin-prettier": "^3.3.1", 45 | "jest": "^26.6.3", 46 | "prettier": "^2.2.1", 47 | "react": "^17.0.1", 48 | "react-test-renderer": "^17.0.1", 49 | "ts-jest": "^26.4.4", 50 | "typescript": "^4.1.2" 51 | }, 52 | "dependencies": {} 53 | } 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type Subscriber = (val: T) => void; 4 | 5 | class Observer { 6 | subscribers: Set>; 7 | value: T; 8 | 9 | constructor(val: T) { 10 | this.value = val; 11 | this.subscribers = new Set>(); 12 | } 13 | 14 | unsubscribe(fn: Subscriber): () => void { 15 | return () => { 16 | this.subscribers.delete(fn); 17 | }; 18 | } 19 | 20 | subscribe(fn: Subscriber): () => void { 21 | this.subscribers.add(fn); 22 | return this.unsubscribe(fn); 23 | } 24 | 25 | update(value: T | ((oldValue: T) => T)): void { 26 | if (isFunction(value)) { 27 | const newVal = value(this.value); 28 | this.value = newVal; 29 | } else { 30 | this.value = value; 31 | } 32 | 33 | for (const sub of this.subscribers) { 34 | sub(this.value); 35 | } 36 | } 37 | } 38 | 39 | function isFunction(val: any): val is Function { 40 | return typeof val === "function"; 41 | } 42 | 43 | type Setter = (newValue: T | ((oldVal: T) => T)) => void; 44 | 45 | /** 46 | * Returns a tuple of [0] a hook that works like useState, but for state across 47 | * components, [1] a setter that sets state, regardless on context, and [2] an 48 | * observer that accepts subscriptions to state updates. 49 | * 50 | * @param initialValue the default initial value the hook will receive 51 | */ 52 | export function createGalactic( 53 | initialValue: T 54 | ): [() => [T, Setter], Setter, Observer] { 55 | const observer = new Observer(initialValue); 56 | 57 | function useGalacticState(): [T, Setter] { 58 | const [state, setState] = React.useState(observer.value); 59 | 60 | React.useEffect(() => { 61 | return observer.subscribe(setState); 62 | }, []); 63 | 64 | return React.useMemo(() => [state, setGalacticState], [state]); 65 | } 66 | 67 | function setGalacticState(newVal: T | ((oldVal: T) => T)) { 68 | observer.update(newVal); 69 | } 70 | 71 | return [useGalacticState, setGalacticState, observer]; 72 | } 73 | -------------------------------------------------------------------------------- /__test__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react-hooks"; 2 | import { createGalactic } from "../src"; 3 | 4 | describe("GalacticState", () => { 5 | describe("without observer", () => { 6 | test("value update across components", () => { 7 | const [useCounter, setCounter] = createGalactic(0); 8 | 9 | const { result: result1, rerender: rerender1 } = renderHook(() => 10 | useCounter() 11 | ); 12 | const { result: result2, rerender: rerender2 } = renderHook(() => 13 | useCounter() 14 | ); 15 | 16 | act(() => { 17 | setCounter(1); 18 | }); 19 | 20 | rerender1(); 21 | rerender2(); 22 | 23 | expect(result1.current[0]).toBe(1); 24 | expect(result2.current[0]).toBe(1); 25 | }); 26 | 27 | test("update value with callback", () => { 28 | const [useCounter, setCounter] = createGalactic(0); 29 | const { result: result1, rerender: rerender1 } = renderHook(() => 30 | useCounter() 31 | ); 32 | const { result: result2, rerender: rerender2 } = renderHook(() => 33 | useCounter() 34 | ); 35 | 36 | act(() => { 37 | setCounter((current) => current + 1); 38 | }); 39 | 40 | rerender1(); 41 | rerender2(); 42 | 43 | expect(result1.current[0]).toBe(1); 44 | expect(result2.current[0]).toBe(1); 45 | }); 46 | }); 47 | 48 | describe("with observer", () => { 49 | test("observer receives state updates", () => { 50 | const [useCounter, setCounter, counterObserver] = createGalactic(0); 51 | 52 | const { result: result1, rerender: rerender1 } = renderHook(() => 53 | useCounter() 54 | ); 55 | const { result: result2, rerender: rerender2 } = renderHook(() => 56 | useCounter() 57 | ); 58 | 59 | const counterSub = jest.fn(); 60 | 61 | counterObserver.subscribe(counterSub); 62 | 63 | act(() => { 64 | setCounter(1); 65 | }); 66 | 67 | rerender1(); 68 | rerender2(); 69 | 70 | expect(result1.current[0]).toBe(1); 71 | expect(result2.current[0]).toBe(1); 72 | expect(counterSub).toHaveBeenCalledWith(1); 73 | 74 | act(() => { 75 | setCounter(55); 76 | }); 77 | 78 | rerender1(); 79 | 80 | expect(result1.current[0]).toBe(55); 81 | expect(counterSub).toHaveBeenCalledWith(55); 82 | }); 83 | 84 | test("observer updates component states", () => { 85 | const [useCounter, setCounter] = createGalactic(0); 86 | 87 | const { result: result1, rerender: rerender1 } = renderHook(() => 88 | useCounter() 89 | ); 90 | const { result: result2, rerender: rerender2 } = renderHook(() => 91 | useCounter() 92 | ); 93 | 94 | act(() => { 95 | setCounter(55); 96 | }); 97 | 98 | rerender1(); 99 | rerender2(); 100 | 101 | expect(result1.current[0]).toBe(55); 102 | expect(result2.current[0]).toBe(55); 103 | }); 104 | 105 | test("observer updates component states using callback", () => { 106 | const [useCounter, setCounter] = createGalactic(0); 107 | 108 | const { result: result1, rerender: rerender1 } = renderHook(() => 109 | useCounter() 110 | ); 111 | const { result: result2, rerender: rerender2 } = renderHook(() => 112 | useCounter() 113 | ); 114 | 115 | act(() => { 116 | setCounter((currentVal) => currentVal + 55); 117 | }); 118 | 119 | rerender1(); 120 | rerender2(); 121 | 122 | expect(result1.current[0]).toBe(55); 123 | expect(result2.current[0]).toBe(55); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Galactic State 2 | 3 |

4 | galaxy 5 |

6 | 7 | A "global" state library for React, with an API similar to `useState`. 8 | 9 | ![NPM](https://img.shields.io/npm/l/galactic-state) ![npm](https://img.shields.io/npm/v/galactic-state) ![NPM](https://img.shields.io/bundlephobia/minzip/galactic-state) 10 | 11 | ## Install 12 | 13 | `npm i galactic-state` 14 | 15 | ## Usage 16 | 17 | `createGalactic` receives a single argument, which corresponds to the default state value. It returns a tuple with a hook in the first position that works like `useState`, but whose state will be "global", ie any component that uses it will update when its value gets updated. 18 | 19 | ```typescript 20 | // state.js 21 | import { createGalactic } from 'galactic-state'; 22 | 23 | export const [useEmail] = createGalactic(''); 24 | export const [usePassword] = createGalactic(''); 25 | 26 | ... 27 | // Components.jsx 28 | import { useEmail, usePassword } from 'src/state'; 29 | 30 | function Login() { 31 | const [email, setEmail] = useEmail(); 32 | const [password, setPassword] = usePassword(); 33 | 34 | return ( 35 |
36 | setEmail(e.target.value)} 39 | value={email} 40 | /> 41 | setEmail(e.target.value)} 44 | value={password} 45 | /> 46 |
47 | ); 48 | } 49 | 50 | function OtherComponent() { 51 | const [email] = useEmail(); 52 | 53 | return ( 54 |

{email}

// will update when `setEmail` is called in `Login` Component 55 | ); 56 | } 57 | 58 | function App() { 59 | return ( 60 |
61 | 62 | 63 |
64 | ); 65 | } 66 | 67 | ``` 68 | 69 | ### Setter 70 | The second value in the tuple returned from `createGalactic` is a setter function that can be called from anywhere. This is the exact same setter that is returned from the generated hook, and it will have the same function. 71 | 72 | If you have a component that is just setting state and not consuming the state value, you can use this setter instead to prevent unnecessary rerendering of your component. 73 | 74 | You could also use this within a websocket connection to sync your server state and your client state. 75 | 76 | ```javascript 77 | // state.js 78 | export const [useIsAuthenticated, setIsAuthenticated] = createGalactic(false); 79 | 80 | // Login.jsx 81 | import { setIsAuthenticated } from 'src/state'; 82 | 83 | function Login() { 84 | ... 85 | 86 | return ( 87 |
{ 88 | const isAuthenticated = await serverValidateCredentials(email, password); 89 | setIsAuthenticated(isAuthenticated); 90 | }}> 91 | ... 92 |
93 | ) 94 | } 95 | 96 | // Logs out user from the server (for security reasons). 97 | ServiceAuthenticationValidWS.subscribe(isAuthenticated => { 98 | setIsAuthenticated(isAuthenticated) 99 | }) 100 | 101 | ``` 102 | 103 | ### Observer 104 | The third value in the tuple is an observer, which can be used to subscribe to state changes from anywhere in the app, not necessarily within a component. 105 | 106 | ## FAQ 107 | 108 | ### Why this instead of Context? 109 | 110 | React Context's API can be a bit clunky to use. If you have contexts that depend on each other, it can be very tricky to get them to work. You also still have some boilerplate (though not as much as Redux), a learning curve, and you often end up with lots of imports to consume your context. Context can also result in performance issues since providers will rerender the whole app when they render if you're wrapping all your components. 111 | 112 | Galactic State provides a much simpler API and intuitive API which can help create a more performant application. 113 | 114 | ### Why "Galactic"? 115 | 116 | "Global" state has bad connotations, and it needed to have a differentiating name. 117 | --------------------------------------------------------------------------------