├── .browserslistrc ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .travis.yml ├── .yarn └── releases │ └── yarn-2.4.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── hooks │ ├── index.ts │ ├── use-capsule.test.ts │ └── use-capsule.ts ├── index.ts ├── react-capsule.test.ts ├── react-capsule.ts └── utils │ ├── index.ts │ ├── is-state.test.ts │ └── is-state.ts ├── tsconfig.eslint.json └── tsconfig.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | IE 11 2 | last 3 Chrome versions 3 | last 3 Edge versions 4 | last 3 Firefox versions 5 | last 2 Safari versions 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | "node": true 5 | }, 6 | 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:prettier/recommended", 12 | "plugin:react/recommended", 13 | "prettier/@typescript-eslint", 14 | "prettier/react" 15 | ], 16 | 17 | "parser": "@typescript-eslint/parser", 18 | 19 | "parserOptions": { 20 | "ecmaFeatures": { 21 | "experimentalObjectRestSpread": true, 22 | "jsx": false 23 | }, 24 | "extraFileExtensions": [".json"], 25 | "project": "./tsconfig.eslint.json", 26 | "tsconfigRootDir": "./", 27 | "useJSXTextNode": true, 28 | "warnOnUnsupportedTypeScriptVersion": false 29 | }, 30 | 31 | "plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"], 32 | 33 | "rules": { 34 | "prettier/prettier": "error", 35 | "react/prop-types": ["error", { "skipUndeclared": true }], 36 | "react-hooks/exhaustive-deps": "error", 37 | "react-hooks/rules-of-hooks": "error", 38 | "sort-imports": ["error", { "ignoreDeclarationSort": true }] 39 | }, 40 | 41 | "settings": { 42 | "react": { 43 | "version": "detect" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.git/ 2 | /.idea/ 3 | /.vscode/ 4 | /.yarn/cache/ 5 | /.yarn/sdks/ 6 | /.yarn/unplugged/ 7 | /.yarn/build-state.yml 8 | /.yarn/install-state.gz 9 | /dist/ 10 | /jest/ 11 | /node_modules/ 12 | /types/ 13 | /*.iml 14 | /.env 15 | /.pnp.cjs 16 | /.pnp.js 17 | /.tsbuildinfo 18 | /yarn.lock 19 | /yarn-error.log 20 | .DS_Store 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.git/ 2 | /.idea/ 3 | /.vscode/ 4 | /.yarn/ 5 | /jest/ 6 | /node_modules/ 7 | /src/ 8 | /test-utils/ 9 | /*.iml 10 | /.browserlistrc 11 | /.editorconfig 12 | /.env 13 | /.eslintrc.json 14 | /.gitattributes 15 | /.gitignore 16 | /.huskyrc.json 17 | /.lintstagedrc.json 18 | /.npmignore 19 | /.prettierrc.json 20 | /.travis.yml 21 | /.yarnrc.yml 22 | /jest.config.js 23 | /package-lock.json 24 | /rollup.config.js 25 | /tsconfig.eslint.json 26 | /tsconfig.json 27 | /yarn.lock 28 | /yarn-error.log 29 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "quoteProps": "as-needed", 10 | "semi": true, 11 | "singleQuote": true, 12 | "tabWidth": 2, 13 | "trailingComma": "all", 14 | "useTabs": false 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 14 3 | branches: 4 | only: master 5 | install: 6 | - yarn 7 | script: 8 | - yarn build 9 | - yarn test 10 | deploy: 11 | api_key: 12 | secure: qf8eSm6INNTuYpnGEhr8gOd4LkQzYyBLfwuPaRLemWUG4L9cY1eLiMbEhDMlNIkGRKaDGL4f7bwGZEOq9zX25OEzJeFwBs7JlFjPdK7RNp6d1iriayW7xNBkqm/hb9HN0N3A/ubzjWVHNKep08PHlJaJB5UnFvf0qCM9ZeIhbXqeMaNirvk5K0m6MKfb5nG9Yax6icvUZasCUXjdAPCjNCt67PBYdCgKf5Awb7cQHvr6G3GzZjXaScSIQAXYwYXIoelhEJxcHaw4bRuayRXcTX/lsZM9pAH2PQNa2yssrA74SfRdk1b0D/lcnfPTITv6sElJhehL75kAAkcX/Woxd+T+M3hZj7exAjpnc7imscFvRKOjFymUFcVdTyqkGgG9FnuNZuaDfhEBY2htvyCjsaBX97JXpIN3uKHG63mhs3digX6EHf1z1ulop5Qhiqgy0ECCIkKLuib4ebxn3+5gseDWdajFyFQ0ypvZZVPY5G/fgQwv/uB4k4cfYMgBxMToimX/JmE70I/3JGdtjDa4cvxs4EQQ4o7Gh3OvJZpYf12pgk60jCDa5L70Z3UYLGnOdt5+o4GXFdneal/dWTRdtf7DTK3toWonhISYlWu5YV8DpoK+/YXwnC4k6U3/fCzy+c0HvowRple4kSVL5QwvmO+Y1zaG7AWVg0kDprcVJVY= 13 | email: npmjs@charlesstover.com 14 | on: 15 | branch: master 16 | provider: npm 17 | skip_cleanup: true 18 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-2.4.1.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Charles Stover 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 Capsule 💊 [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=You%20might%20not%20need%20the%20React%20Context%20API%20for%20your%20global%20state!&url=https://github.com/CharlesStover/react-capsule&via=CharlesStover&hashtags=react,reactjs,javascript,typescript,webdev,webdevelopment) [![version](https://img.shields.io/npm/v/react-capsule.svg)](https://www.npmjs.com/package/react-capsule) [![minzipped size](https://img.shields.io/bundlephobia/minzip/react-capsule.svg)](https://www.npmjs.com/package/react-capsule) [![downloads](https://img.shields.io/npm/dt/react-capsule.svg)](https://www.npmjs.com/package/react-capsule) [![build](https://api.travis-ci.com/CharlesStover/react-capsule.svg)](https://travis-ci.com/CharlesStover/react-capsule/) 2 | 3 | React Capsule 💊 is a way to share your global React state between components 4 | without managing yet another React context. 5 | 6 | [![banner](https://user-images.githubusercontent.com/343837/86960994-48518f00-c115-11ea-8309-940f228013f9.png)](https://github.com/CharlesStover/react-capsule) 7 | 8 | React Capsule is **simple**, **light-weight**, **easy to use**, and 9 | **easy to understand**. 10 | 11 | ```javascript 12 | // Create a capsule by giving it an initial value. 13 | import Capsule from 'react-capsule'; 14 | export default new Capsule('my value'); 15 | ``` 16 | 17 | ```jsx 18 | // Using capsules with function components. 19 | import { useCapsule } from 'react-capsule'; 20 | import myCapsule from '...'; 21 | 22 | export default function MyComponent() { 23 | const [value, setValue] = useCapsule(myCapsule); // 💊 24 | 25 | // Change my capsule's value on click. 26 | const handleClick = React.useCallback(() => { 27 | setValue('your value'); 28 | }, [setValue]); 29 | 30 | return ; // my value 31 | } 32 | ``` 33 | 34 | ```jsx 35 | // Using capsules with class components. 36 | import myCapsule from '...'; 37 | 38 | export default class MyComponent { 39 | // When the component mounts, re-render on state change. 40 | componentDidMount() { 41 | myCapsule.subscribe(this.forceUpdate); 42 | } 43 | 44 | // When the component unmounts, remove the re-render subscription. 45 | componentWillUnmount() { 46 | myCapsule.unsubscribe(this.forceUpdate); 47 | } 48 | 49 | handleClick() { 50 | myCapsule.setState('your value'); 51 | } 52 | 53 | render() { 54 | // my value 55 | return ; 56 | } 57 | } 58 | ``` 59 | 60 | ## Install 61 | 62 | React Capsule 💊 is available as `react-capsule` on NPM. 63 | 64 | ``` 65 | npm install react-capsule 66 | ``` 67 | 68 | ``` 69 | yarn add react-capsule 70 | ``` 71 | 72 | ## API 73 | 74 | Each capsule has a few useful methods for extending functionality or fine-tuning 75 | unit tests. 76 | 77 | ### `reset` 78 | 79 | `capsule.reset()` will reset a capsule back to its initial value. This is useful 80 | when allowing customers to reset forms, allowing customers to reset cached API 81 | results, and to run as a `beforeEach` in your Jest unit tests. 82 | 83 | ### `setState` 84 | 85 | `capsule.setState(newValue)` allows you to update the capsule's value. This is 86 | useful when you want to write utility functions outside a React component's 87 | render lifecycle. 88 | 89 | ### `state` 90 | 91 | `capsule.state` contains the capsule's current value. 92 | 93 | ### `subscribe` 94 | 95 | `capsule.subscribe(callback)` will execute the provided callback function 96 | whenever the state changes. This method returns a function that will unsubscribe 97 | the specified callback function. 98 | 99 | ### `unsubscribe` 100 | 101 | `capsule.unsubscribe(callback)` will unsubscribe the specified callback function 102 | from future state changes. 103 | 104 | ## Sponsor 💗 105 | 106 | If you are a fan of this project, you may 107 | [become a sponsor](https://github.com/sponsors/CharlesStover) 108 | via GitHub's Sponsors Program. 109 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | cacheDirectory: './jest/cache', 3 | collectCoverage: true, 4 | collectCoverageFrom: [ 5 | '/src/**/*.{ts,tsx}', 6 | '!/src/**/*.d.ts', 7 | '!/src/**/*.test.{ts,tsx}', 8 | '!/src/**/test-utils/*.{ts,tsx}', 9 | ], 10 | coverageDirectory: './jest/coverage', 11 | coverageThreshold: { 12 | global: { 13 | branches: 100, 14 | functions: 100, 15 | lines: 100, 16 | statements: 100, 17 | }, 18 | }, 19 | resetMocks: true, 20 | resetModules: true, 21 | restoreMocks: true, 22 | roots: ['/src'], 23 | transform: { 24 | '^.+\\.tsx?$': 'ts-jest', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-capsule", 3 | "version": "1.0.3", 4 | "author": "Charles Stover ", 5 | "description": "Creates an encapsulated global state", 6 | "homepage": "https://www.npmjs.com/package/react-capsule", 7 | "license": "MIT", 8 | "main": "./dist/cjs/index.js", 9 | "module": "./dist/esm/index.js", 10 | "repository": "https://github.com/CharlesStover/react-capsule", 11 | "type": "module", 12 | "types": "./types/index.d.ts", 13 | "bugs": { 14 | "email": "react-capsule@charlesstover.com", 15 | "url": "https://github.com/CharlesStover/react-capsule/issues" 16 | }, 17 | "directories": { 18 | "lib": "src" 19 | }, 20 | "files": [ 21 | "dist", 22 | "types" 23 | ], 24 | "publishConfig": { 25 | "access": "public", 26 | "main": "./dist/cjs/index.js", 27 | "module": "./dist/esm/index.js" 28 | }, 29 | "scripts": { 30 | "build": "rollup --config", 31 | "clean": "rm -rf dist jest node_modules package-lock.json yarn.lock", 32 | "jest": "jest", 33 | "start": "rollup --config --watch", 34 | "test": "eslint && jest", 35 | "test-watch": "jest --coverage=false --watch", 36 | "yarn-upgrade": "yarn up * && yarn up @*/*", 37 | "yarn-vscode": "yarn dlx @yarnpkg/pnpify --sdk vscode" 38 | }, 39 | "dependencies": { 40 | "use-force-update": "^1.0.7" 41 | }, 42 | "devDependencies": { 43 | "@rollup/plugin-commonjs": "^16.0.0", 44 | "@rollup/plugin-node-resolve": "^10.0.0", 45 | "@testing-library/react-hooks": "^3.4.2", 46 | "@types/jest": "^26.0.15", 47 | "@types/node": "^14.14.6", 48 | "@types/react": "^16.9.55", 49 | "@types/react-dom": "^16.9.9", 50 | "@types/testing-library__react-hooks": "^3.4.1", 51 | "@typescript-eslint/eslint-plugin": "^4.6.1", 52 | "@typescript-eslint/parser": "^4.6.1", 53 | "eslint": "^7.12.1", 54 | "eslint-config-prettier": "^6.15.0", 55 | "eslint-plugin-prettier": "^3.1.4", 56 | "eslint-plugin-react": "^7.21.5", 57 | "eslint-plugin-react-hooks": "^4.2.0", 58 | "jest": "^26.6.3", 59 | "prettier": "^2.1.2", 60 | "react": "^17.0.1", 61 | "react-dom": "^17.0.1", 62 | "react-test-renderer": "^17.0.1", 63 | "rollup": "^2.33.1", 64 | "rollup-plugin-typescript2": "^0.29.0", 65 | "ts-jest": "^26.4.3", 66 | "tslib": "^2.0.3", 67 | "typescript": "^4.0.5" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16.8.0", 71 | "react-dom": ">=16.8.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import nodeResolve from '@rollup/plugin-node-resolve'; 3 | import path from 'path'; 4 | import typescript2 from 'rollup-plugin-typescript2'; 5 | import packageJson from './package.json'; 6 | 7 | const EXTERNAL = new Set([ 8 | ...Object.keys(packageJson.dependencies), 9 | ...Object.keys(packageJson.peerDependencies), 10 | ]); 11 | 12 | const IS_DEV = process.env.NODE_ENV === 'development'; 13 | 14 | const MAIN_DIR = path.parse(packageJson.main).dir; 15 | 16 | const MODULE_DIR = path.parse(packageJson.module).dir; 17 | 18 | export default [ 19 | { 20 | cache: true, 21 | external(id) { 22 | if (EXTERNAL.has(id)) { 23 | return true; 24 | } 25 | 26 | for (const pkg of EXTERNAL) { 27 | if (id.startsWith(`${pkg}/`)) { 28 | return true; 29 | } 30 | } 31 | 32 | return false; 33 | }, 34 | input: 'src/index.ts', 35 | output: [ 36 | { 37 | dir: MAIN_DIR, 38 | exports: 'named', 39 | format: 'cjs', 40 | sourcemap: IS_DEV, 41 | }, 42 | { 43 | dir: MODULE_DIR, 44 | format: 'es', 45 | sourcemap: IS_DEV, 46 | }, 47 | ], 48 | plugins: [ 49 | nodeResolve({ 50 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 51 | preferBuiltins: true, 52 | }), 53 | commonjs({ 54 | extensions: ['.js', '.jsx'], 55 | }), 56 | typescript2({ 57 | check: !IS_DEV, 58 | useTsconfigDeclarationDir: true, 59 | }), 60 | ], 61 | treeshake: !IS_DEV, 62 | watch: { 63 | exclude: 'node_modules/**', 64 | }, 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useCapsule } from './use-capsule'; 2 | -------------------------------------------------------------------------------- /src/hooks/use-capsule.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RenderHookResult, 3 | act, 4 | renderHook, 5 | } from '@testing-library/react-hooks'; 6 | import { Dispatch, SetStateAction } from 'react'; 7 | import Capsule, { useCapsule } from '..'; 8 | 9 | const render = ( 10 | initialState: T, 11 | ): RenderHookResult>]> => { 12 | const capsule: Capsule = new Capsule(initialState); 13 | return renderHook((): [T, Dispatch>] => 14 | useCapsule(capsule), 15 | ); 16 | }; 17 | 18 | describe('useCapsule', (): void => { 19 | describe('state', (): void => { 20 | it('should be the state', (): void => { 21 | const INITIAL_STATE = 'initial state'; 22 | const { result } = render(INITIAL_STATE); 23 | expect(result.current[0]).toBe(INITIAL_STATE); 24 | }); 25 | }); 26 | 27 | describe('dispatch', (): void => { 28 | it('should update the state with a value', (): void => { 29 | const INITIAL_STATE = 'initial state'; 30 | const NEW_STATE = 'new state'; 31 | const { result } = render(INITIAL_STATE); 32 | expect(result.current[0]).not.toBe(NEW_STATE); 33 | 34 | act((): void => { 35 | result.current[1](NEW_STATE); 36 | }); 37 | 38 | expect(result.current[0]).toBe(NEW_STATE); 39 | }); 40 | 41 | it('should update the state with a reducer', (): void => { 42 | const INITIAL_STATE = 1; 43 | const EXPECTED_STATE = 3; 44 | const { result } = render(INITIAL_STATE); 45 | expect(result.current[0]).not.toBe(EXPECTED_STATE); 46 | 47 | act((): void => { 48 | result.current[1]((prevState: number): number => prevState + 2); 49 | }); 50 | 51 | expect(result.current[0]).toBe(EXPECTED_STATE); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/hooks/use-capsule.ts: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react'; 2 | import useForceUpdate from 'use-force-update'; 3 | import ReactCapsule from '..'; 4 | import { isState } from '../utils'; 5 | 6 | type VoidFunction = () => void; 7 | 8 | export default function useCapsule( 9 | capsule: ReactCapsule, 10 | ): [T, Dispatch>] { 11 | const forceUpdate: VoidFunction = useForceUpdate(); 12 | 13 | const dispatch: Dispatch> = React.useCallback( 14 | (newValue: SetStateAction): void => { 15 | if (isState(newValue)) { 16 | capsule.setState(newValue); 17 | return; 18 | } 19 | const reducedValue: T = newValue(capsule.state); 20 | capsule.setState(reducedValue); 21 | }, 22 | [capsule], 23 | ); 24 | 25 | React.useLayoutEffect((): VoidFunction => { 26 | return capsule.subscribe(forceUpdate); 27 | }, [capsule, forceUpdate]); 28 | 29 | return [capsule.state, dispatch]; 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useCapsule } from './hooks'; 2 | export { default } from './react-capsule'; 3 | -------------------------------------------------------------------------------- /src/react-capsule.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import Capsule from '.'; 3 | 4 | interface State { 5 | a: boolean; 6 | b: number; 7 | c: string; 8 | } 9 | 10 | type VoidFunction = () => void; 11 | 12 | const INITIAL_STATE: State = { 13 | a: false, 14 | b: 0, 15 | c: '', 16 | }; 17 | 18 | const NEW_STATE: State = { 19 | a: true, 20 | b: 1, 21 | c: 'string', 22 | }; 23 | 24 | describe('ReactCapsule', (): void => { 25 | describe('constructor', (): void => { 26 | it('should set the state', (): void => { 27 | const capsule: Capsule = new Capsule(INITIAL_STATE); 28 | 29 | expect(capsule.state).toEqual(INITIAL_STATE); 30 | }); 31 | }); 32 | 33 | describe('reset', (): void => { 34 | it('should reset the state', (): void => { 35 | const capsule: Capsule = new Capsule(INITIAL_STATE); 36 | capsule.setState(NEW_STATE); 37 | expect(capsule.state).not.toEqual(INITIAL_STATE); 38 | 39 | capsule.reset(); 40 | 41 | expect(capsule.state).toEqual(INITIAL_STATE); 42 | }); 43 | }); 44 | 45 | describe('setState', (): void => { 46 | it('should set the state', (): void => { 47 | const capsule: Capsule = new Capsule(INITIAL_STATE); 48 | expect(capsule.state).not.toEqual(NEW_STATE); 49 | 50 | capsule.setState(NEW_STATE); 51 | 52 | expect(capsule.state).toEqual(NEW_STATE); 53 | }); 54 | 55 | it('should fire subscriptions', (): void => { 56 | const TEST_SUBSCRIPTION_1: VoidFunction = jest.fn(); 57 | const TEST_SUBSCRIPTION_2: VoidFunction = jest.fn(); 58 | const capsule: Capsule = new Capsule(INITIAL_STATE); 59 | expect(TEST_SUBSCRIPTION_1).not.toHaveBeenCalled(); 60 | expect(TEST_SUBSCRIPTION_2).not.toHaveBeenCalled(); 61 | 62 | capsule.subscribe(TEST_SUBSCRIPTION_1); 63 | capsule.subscribe(TEST_SUBSCRIPTION_2); 64 | capsule.setState(NEW_STATE); 65 | 66 | expect(TEST_SUBSCRIPTION_1).toHaveBeenCalledTimes(1); 67 | expect(TEST_SUBSCRIPTION_2).toHaveBeenCalledTimes(1); 68 | }); 69 | }); 70 | 71 | describe('subscribe', (): void => { 72 | it('should return an unsubscribe function', (): void => { 73 | const TEST_SUBSCRIPTION_1: VoidFunction = jest.fn(); 74 | const TEST_SUBSCRIPTION_2: VoidFunction = jest.fn(); 75 | const capsule: Capsule = new Capsule(INITIAL_STATE); 76 | expect(TEST_SUBSCRIPTION_1).not.toHaveBeenCalled(); 77 | expect(TEST_SUBSCRIPTION_2).not.toHaveBeenCalled(); 78 | capsule.subscribe(TEST_SUBSCRIPTION_1); 79 | const unsubscribe2 = capsule.subscribe(TEST_SUBSCRIPTION_2); 80 | capsule.setState(NEW_STATE); 81 | expect(TEST_SUBSCRIPTION_1).toHaveBeenCalledTimes(1); 82 | expect(TEST_SUBSCRIPTION_2).toHaveBeenCalledTimes(1); 83 | 84 | unsubscribe2(); 85 | capsule.setState(INITIAL_STATE); 86 | 87 | expect(TEST_SUBSCRIPTION_1).toHaveBeenCalledTimes(2); 88 | expect(TEST_SUBSCRIPTION_2).toHaveBeenCalledTimes(1); 89 | }); 90 | }); 91 | 92 | describe('unsubscribe', (): void => { 93 | it('should remove subscriptions', (): void => { 94 | const TEST_SUBSCRIPTION_1: VoidFunction = jest.fn(); 95 | const TEST_SUBSCRIPTION_2: VoidFunction = jest.fn(); 96 | const capsule: Capsule = new Capsule(INITIAL_STATE); 97 | expect(TEST_SUBSCRIPTION_1).not.toHaveBeenCalled(); 98 | expect(TEST_SUBSCRIPTION_2).not.toHaveBeenCalled(); 99 | capsule.subscribe(TEST_SUBSCRIPTION_1); 100 | capsule.subscribe(TEST_SUBSCRIPTION_2); 101 | capsule.setState(NEW_STATE); 102 | expect(TEST_SUBSCRIPTION_1).toHaveBeenCalledTimes(1); 103 | expect(TEST_SUBSCRIPTION_2).toHaveBeenCalledTimes(1); 104 | 105 | capsule.unsubscribe(TEST_SUBSCRIPTION_2); 106 | capsule.setState(INITIAL_STATE); 107 | 108 | expect(TEST_SUBSCRIPTION_1).toHaveBeenCalledTimes(2); 109 | expect(TEST_SUBSCRIPTION_2).toHaveBeenCalledTimes(1); 110 | }); 111 | }); 112 | 113 | describe('useState', (): void => { 114 | it('should be a React hook', (): void => { 115 | const capsule: Capsule = new Capsule(INITIAL_STATE); 116 | const { result } = renderHook(capsule.useState); 117 | expect(result.error).toBeUndefined(); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/react-capsule.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { useCapsule } from './hooks'; 3 | 4 | type VoidFunction = () => void; 5 | 6 | export default class ReactCapsule { 7 | private _initialValue: T; 8 | private _subscriptions: Set = new Set(); 9 | private _value: T; 10 | 11 | public constructor(initialValue: T) { 12 | this._initialValue = initialValue; 13 | this._value = initialValue; 14 | 15 | this.reset = this.reset.bind(this); 16 | this.setState = this.setState.bind(this); 17 | this.subscribe = this.subscribe.bind(this); 18 | this.unsubscribe = this.unsubscribe.bind(this); 19 | this.useState = this.useState.bind(this); 20 | } 21 | 22 | public reset(): void { 23 | this._value = this._initialValue; 24 | } 25 | 26 | public setState(newValue: T): void { 27 | this._value = newValue; 28 | for (const subscription of this._subscriptions) { 29 | subscription(); 30 | } 31 | } 32 | 33 | public get state(): T { 34 | return this._value; 35 | } 36 | 37 | public subscribe(callback: VoidFunction): VoidFunction { 38 | this._subscriptions.add(callback); 39 | return (): void => { 40 | this.unsubscribe(callback); 41 | }; 42 | } 43 | 44 | public unsubscribe(callback: VoidFunction): void { 45 | this._subscriptions.delete(callback); 46 | } 47 | 48 | public useState(): [T, Dispatch>] { 49 | /* 50 | ESLint mistakenly believes that we are calling a hook from within a class 51 | component instead of from within a function component or another hook. 52 | However, this method actually is a hook. 53 | */ 54 | // eslint-disable-next-line react-hooks/rules-of-hooks 55 | return useCapsule(this); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as isState } from './is-state'; 2 | -------------------------------------------------------------------------------- /src/utils/is-state.test.ts: -------------------------------------------------------------------------------- 1 | import { isState } from '.'; 2 | 3 | describe('isState', (): void => { 4 | it('should return true for non-functions', (): void => { 5 | expect(isState(0)).toBe(true); 6 | expect(isState(1)).toBe(true); 7 | expect(isState(-1)).toBe(true); 8 | expect(isState(NaN)).toBe(true); 9 | expect(isState('')).toBe(true); 10 | expect(isState('string')).toBe(true); 11 | expect(isState({})).toBe(true); 12 | expect(isState(new Date())).toBe(true); 13 | expect(isState(true)).toBe(true); 14 | expect(isState(false)).toBe(true); 15 | expect(isState(null)).toBe(true); 16 | expect(isState(undefined)).toBe(true); 17 | }); 18 | 19 | it('should return false for functions', (): void => { 20 | expect(isState((): void => undefined)).toBe(false); 21 | expect( 22 | isState(function (): void { 23 | return; 24 | }), 25 | ).toBe(false); 26 | expect(isState(new Function())).toBe(false); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/utils/is-state.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction } from 'react'; 2 | 3 | /* 4 | isState takes a set state action and determines if that action is the new state 5 | or a reducer. This type guard allows TypeScript to determine that value can be 6 | executed as value(prevValue) when value is a reducer. 7 | As a limitation of React hooks, a function state cannot be passed directly as a 8 | set state action, as it will be assumed to be a reducer. 9 | */ 10 | 11 | export default function isState(value: SetStateAction): value is T { 12 | return typeof value !== 'function'; 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | }, 5 | "exclude": ["/.yarn/", "/dist/", "/jest/", "/node_modules/", "/types/"], 6 | "extends": "./tsconfig.json", 7 | "include": [ 8 | "**/*.cjs", 9 | "**/*.d.ts", 10 | "**/*.js", 11 | "**/*.json", 12 | "**/*.ts", 13 | "**/*.tsx", 14 | "**/*.yml" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUmdGlobalAccess": false, 6 | "allowUnreachableCode": false, 7 | "allowUnusedLabels": false, 8 | "alwaysStrict": true, 9 | "checkJs": false, 10 | "declaration": true, 11 | "declarationDir": "types", 12 | "declarationMap": false, 13 | "downlevelIteration": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "incremental": false, 17 | "inlineSourceMap": false, 18 | "isolatedModules": false, 19 | "jsx": "react", 20 | "lib": ["DOM", "ESNext"], 21 | "module": "ESNext", 22 | "moduleResolution": "node", 23 | "noFallthroughCasesInSwitch": true, 24 | "noImplicitAny": true, 25 | "noImplicitReturns": true, 26 | "noImplicitThis": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "outDir": "dist", 30 | "removeComments": true, 31 | "resolveJsonModule": true, 32 | "rootDir": "src", 33 | "skipLibCheck": false, 34 | "sourceMap": true, 35 | "strict": true, 36 | "strictBindCallApply": true, 37 | "strictFunctionTypes": true, 38 | "strictNullChecks": true, 39 | "strictPropertyInitialization": true, 40 | "suppressImplicitAnyIndexErrors": false, 41 | "target": "ES5" 42 | }, 43 | "exclude": [ 44 | "/.yarn/", 45 | "/dist/", 46 | "/jest/", 47 | "/node_modules/", 48 | "/types/", 49 | "**/test-utils/*.ts", 50 | "**/test-utils/*.tsx", 51 | "**/*.test.ts", 52 | "**/*.test.tsx" 53 | ], 54 | "include": ["src/**/*"] 55 | } 56 | --------------------------------------------------------------------------------