├── .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 💊 [](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) [](https://www.npmjs.com/package/react-capsule) [](https://www.npmjs.com/package/react-capsule) [](https://www.npmjs.com/package/react-capsule) [](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 | [](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 |
--------------------------------------------------------------------------------