├── .npmignore ├── src ├── store.js ├── EntityContext.js ├── useEntityBoundary.js ├── index.js ├── makeEntity.js ├── useEntity.js ├── EntityScope.js ├── utils.js ├── useUnscopedEntity.js ├── __tests__ │ ├── makeEntity.test.js │ ├── useEntityBoundary.test.js │ ├── utils.test.js │ ├── EntityScope.test.js │ ├── createEntity.test.js │ └── useEntity.test.js └── createEntity.js ├── doc └── logo.png ├── .travis.yml ├── test-setup.js ├── _config.yml ├── CHANGELOG.md ├── .editorconfig ├── .gitignore ├── .prettierrc ├── .babelrc.js ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── LICENSE.md ├── package.json ├── index.d.ts └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | # misc 2 | .DS_Store -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | export const store = []; 2 | export default store; 3 | -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/react-entities/HEAD/doc/logo.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | cache: npm 4 | script: 5 | - npm test 6 | after_success: 7 | - npm run test:cov -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/EntityContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const EntityContext = createContext({}); 4 | export default EntityContext; 5 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | title: React Entities 2 | logo: https://arnelenero.github.io/react-entities/doc/logo.png 3 | markdown: CommonMarkGhPages 4 | theme: jekyll-theme-architect 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | The documentation of all changes included in each release can be found at our [Releases](https://github.com/arnelenero/react-entities/releases) page on GitHub. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = off 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /src/useEntityBoundary.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import store from './store'; 4 | 5 | export const useEntityBoundary = () => { 6 | useEffect(() => { 7 | return () => { 8 | for (let i = 0; i < store.length; i++) { 9 | store[i].reset(); 10 | } 11 | }; 12 | }, []); 13 | }; 14 | 15 | export default useEntityBoundary; 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as createEntity } from './createEntity'; 2 | export { default as makeEntity } from './makeEntity'; 3 | export { default as EntityScope } from './EntityScope'; 4 | export { default as useEntity } from './useEntity'; 5 | export { default as useEntityBoundary } from './useEntityBoundary'; 6 | export { selectAll, selectNone, strictEqual, shallowEqual } from './utils'; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # production 12 | lib 13 | es 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | .vscode 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "jsxBracketSameLine": false, 6 | "jsxSingleQuote": false, 7 | "overrides": [ 8 | { 9 | "files": [".prettierrc", ".babelrc", ".eslintrc"], 10 | "options": { 11 | "parser": "json" 12 | } 13 | } 14 | ], 15 | "printWidth": 80, 16 | "singleQuote": true, 17 | "semi": true, 18 | "tabWidth": 2, 19 | "trailingComma": "es5", 20 | "useTabs": false 21 | } 22 | -------------------------------------------------------------------------------- /src/makeEntity.js: -------------------------------------------------------------------------------- 1 | import createEntity from './createEntity'; 2 | import useUnscopedEntity from './useUnscopedEntity'; 3 | import { store } from './store'; 4 | 5 | export const makeEntity = (definition, deps) => { 6 | const entity = createEntity(definition, deps); 7 | 8 | // Save reference to this entity for use with useEntityBoundary hook 9 | store.push(entity); 10 | 11 | return (selector, equalityFn) => 12 | useUnscopedEntity(entity, selector, equalityFn); 13 | }; 14 | 15 | export default makeEntity; 16 | -------------------------------------------------------------------------------- /src/useEntity.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import EntityContext from './EntityContext'; 4 | import useUnscopedEntity from './useUnscopedEntity'; 5 | 6 | export const useEntity = (entity, selector, equalityFn) => { 7 | const entities = useContext(EntityContext); 8 | 9 | if (typeof entity !== 'string' || !entities[entity]) 10 | throw new Error(`Invalid entity reference: ${entity}`); 11 | 12 | return useUnscopedEntity(entities[entity], selector, equalityFn); 13 | }; 14 | 15 | export default useEntity; 16 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env; 2 | const commonjs = NODE_ENV === 'test' || BABEL_ENV === 'commonjs'; 3 | const loose = true; 4 | 5 | module.exports = { 6 | presets: [['@babel/env', { loose, modules: false }], '@babel/react'], 7 | plugins: [ 8 | ['@babel/proposal-object-rest-spread', { loose }], 9 | ['@babel/proposal-class-properties', { loose }], 10 | commonjs && ['@babel/transform-modules-commonjs', { loose }], 11 | ['@babel/transform-runtime', { useESModules: !commonjs }], 12 | ].filter(Boolean), 13 | }; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/EntityScope.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { createEntity } from './createEntity'; 3 | import EntityContext from './EntityContext'; 4 | 5 | export const EntityScope = ({ entities, children }) => { 6 | const inheritedEntities = useContext(EntityContext); 7 | const instances = { ...inheritedEntities }; 8 | 9 | for (let k in entities) { 10 | const item = entities[k]; 11 | const entity = item instanceof Array ? item[0] : item; 12 | const deps = item instanceof Array ? item[1] : undefined; 13 | instances[k] = createEntity(entity, deps); 14 | } 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export default EntityScope; 24 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* Basic selectors */ 2 | export const selectAll = state => state; 3 | export const selectNone = _ => null; 4 | 5 | /* Basic equality functions */ 6 | export const strictEqual = (a, b) => a === b; 7 | export const shallowEqual = (a, b) => 8 | strictEqual(a, b) || (bothObjects(a, b) && equalProps(a, b)); 9 | 10 | const bothObjects = (a, b) => 11 | typeof a === 'object' && a !== null && typeof b === 'object' && b !== null; 12 | 13 | const equalProps = (a, b) => { 14 | const keysOfA = Object.keys(a); 15 | const keysOfB = Object.keys(b); 16 | if (keysOfA.length !== keysOfB.length) return false; 17 | for (let i = 0; i < keysOfA.length; i++) { 18 | const key = keysOfA[i]; 19 | if (!b.hasOwnProperty(key) || a[key] !== b[key]) return false; 20 | } 21 | return true; 22 | }; 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/useUnscopedEntity.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | 3 | import { selectAll, strictEqual } from './utils'; 4 | 5 | export const useUnscopedEntity = ( 6 | entity, 7 | selector = selectAll, 8 | equalityFn = strictEqual 9 | ) => { 10 | const selected = selector(entity.state); 11 | 12 | const [state, setState] = useState(selected); 13 | 14 | const subscriberFn = useCallback( 15 | newState => { 16 | const newSelected = selector(newState); 17 | const hasChanged = !equalityFn(state, newSelected); 18 | if (hasChanged) setState(newSelected); 19 | }, 20 | [selector, equalityFn, state] 21 | ); 22 | 23 | useEffect(() => { 24 | entity.subscribers.push(subscriberFn); 25 | return () => { 26 | for (let i = 0, c = entity.subscribers.length; i < c; i++) { 27 | if (entity.subscribers[i] === subscriberFn) { 28 | entity.subscribers[i] = null; 29 | break; 30 | } 31 | } 32 | }; 33 | }, [subscriberFn, entity.subscribers]); 34 | 35 | return [selected, entity.actions]; 36 | }; 37 | 38 | export default useUnscopedEntity; 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Arnel Enero 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 | -------------------------------------------------------------------------------- /src/__tests__/makeEntity.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import makeEntity from '../makeEntity'; 5 | 6 | describe('makeEntity', () => { 7 | const counter = { 8 | initialState: { value: 0 }, 9 | increment: entity => by => { 10 | entity.setState({ value: entity.state.value + by }); 11 | }, 12 | }; 13 | 14 | const mountCounter = () => { 15 | useCounter = makeEntity(counter); 16 | 17 | const CounterView = () => { 18 | hookValue = useCounter(); 19 | return <>; 20 | }; 21 | component = mount(); 22 | }; 23 | 24 | let component = null; 25 | let useCounter = null; 26 | let hookValue = null; 27 | 28 | afterEach(() => { 29 | if (component.exists()) component.unmount(); 30 | }); 31 | 32 | it('returns an entity hook function that returns the tuple [state, actions]', () => { 33 | mountCounter(); 34 | 35 | expect(useCounter).toBeInstanceOf(Function); 36 | expect(hookValue).toBeInstanceOf(Array); 37 | expect(hookValue).toHaveLength(2); 38 | expect(hookValue[0]).toBeInstanceOf(Object); 39 | expect(hookValue[0]).toHaveProperty('value', 0); 40 | expect(hookValue[1]).toBeInstanceOf(Object); 41 | expect(hookValue[1]).toHaveProperty('increment'); 42 | expect(hookValue[1].increment).toBeInstanceOf(Function); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/createEntity.js: -------------------------------------------------------------------------------- 1 | export const createSetState = (entity, beforeSetState) => { 2 | return (updates, ...updaterArgs) => { 3 | if (typeof updates === 'function') 4 | updates = updates(entity.state, ...updaterArgs); 5 | 6 | if (typeof beforeSetState === 'function') 7 | beforeSetState(entity.state, updates); 8 | 9 | entity.state = { ...entity.state, ...updates }; 10 | 11 | for (let i = 0; i < entity.subscribers.length; i++) { 12 | if (typeof entity.subscribers[i] === 'function') 13 | entity.subscribers[i](entity.state); 14 | } 15 | 16 | // Cleanup any nullified subscribers due to possible 17 | // component unmounts caused by this app state change 18 | entity.subscribers = entity.subscribers.filter( 19 | item => typeof item === 'function' 20 | ); 21 | }; 22 | }; 23 | 24 | export const bindActions = (actions, entity, deps) => { 25 | const entityActions = {}; 26 | 27 | for (let key in actions) { 28 | if (typeof actions[key] === 'function') { 29 | const action = actions[key](entity, deps); 30 | if (typeof action !== 'function') 31 | throw new Error('Action must be defined using higher-order function.'); 32 | entityActions[key] = action; 33 | } 34 | } 35 | 36 | return entityActions; 37 | }; 38 | 39 | export const createEntity = ( 40 | { initialState, options = {}, ...actions }, 41 | deps 42 | ) => { 43 | const entity = { 44 | state: initialState || {}, 45 | initialState, 46 | subscribers: [], 47 | reset: () => { 48 | entity.state = initialState; 49 | }, 50 | }; 51 | entity.setState = createSetState(entity, options.beforeSetState); 52 | entity.actions = bindActions(actions, entity, deps); 53 | 54 | return entity; 55 | }; 56 | 57 | export default createEntity; 58 | -------------------------------------------------------------------------------- /src/__tests__/useEntityBoundary.test.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { mount } from 'enzyme'; 4 | 5 | import useEntityBoundary from '../useEntityBoundary'; 6 | import makeEntity from '../makeEntity'; 7 | 8 | describe('useEntityBoundary', () => { 9 | const TestShell = () => { 10 | useEntityBoundary(); 11 | 12 | return ; 13 | }; 14 | 15 | const initialState = { value: 0 }; 16 | const increment = counter => () => { 17 | counter.setState({ value: counter.state.value + 1 }); 18 | }; 19 | const decrement = counter => () => { 20 | counter.setState({ value: counter.state.value - 1 }); 21 | }; 22 | const useEntityA = makeEntity({ initialState, increment }); 23 | const useEntityB = makeEntity({ initialState, decrement }); 24 | 25 | const CounterView = () => { 26 | hookValueA = useEntityA(); 27 | hookValueB = useEntityB(); 28 | 29 | useEffect(() => { 30 | mountCount++; 31 | }, []); 32 | 33 | return null; 34 | }; 35 | 36 | let component = null; 37 | let hookValueA = null; 38 | let hookValueB = null; 39 | let mountCount = 0; 40 | 41 | afterEach(() => { 42 | if (component.exists()) component.unmount(); 43 | }); 44 | 45 | it('resets entities to initial state every time the component unmounts', () => { 46 | component = mount(); 47 | 48 | const prevMountCount = mountCount; 49 | const { increment } = hookValueA[1]; 50 | const { decrement } = hookValueB[1]; 51 | 52 | expect(hookValueA[0]).toHaveProperty('value', 0); 53 | act(() => { 54 | increment(); 55 | }); 56 | expect(hookValueA[0]).toHaveProperty('value', 1); 57 | 58 | expect(hookValueB[0]).toHaveProperty('value', 0); 59 | act(() => { 60 | decrement(); 61 | }); 62 | expect(hookValueB[0]).toHaveProperty('value', -1); 63 | 64 | component.unmount(); 65 | 66 | component = mount(); 67 | expect(mountCount).toBe(prevMountCount + 1); 68 | expect(hookValueA[0]).toHaveProperty('value', 0); 69 | expect(hookValueB[0]).toHaveProperty('value', 0); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { selectAll, selectNone, strictEqual, shallowEqual } from '../utils'; 2 | 3 | describe('selectAll', () => { 4 | it('returns the exact object', () => { 5 | const obj = { id: 1 }; 6 | const all = selectAll(obj); 7 | expect(all).toBe(obj); 8 | }); 9 | }); 10 | 11 | describe('selectNone', () => { 12 | it('always returns null', () => { 13 | const obj = { id: 1 }; 14 | const none = selectNone(obj); 15 | expect(none).toBe(null); 16 | }); 17 | }); 18 | 19 | describe('strictEqual', () => { 20 | it('returns true when references to the same object are passed', () => { 21 | const a = { id: 1 }; 22 | const b = a; 23 | const equal = strictEqual(a, b); 24 | expect(equal).toBe(true); 25 | }); 26 | }); 27 | 28 | describe('strictEqual', () => { 29 | it('returns false when given 2 different objects even if their props are equal', () => { 30 | const a = { id: 1 }; 31 | const b = { id: 1 }; 32 | const equal = strictEqual(a, b); 33 | expect(equal).toBe(false); 34 | }); 35 | }); 36 | 37 | describe('shallowEqual', () => { 38 | it('returns true if the 2 different objects have exactly the same props', () => { 39 | const a = { id: 1 }; 40 | const b = { id: 1 }; 41 | const equal = shallowEqual(a, b); 42 | expect(equal).toBe(true); 43 | }); 44 | }); 45 | 46 | describe('shallowEqual', () => { 47 | it('returns false if the 2 objects have props that are not strictly equal, i.e. `!==`', () => { 48 | const a = { id: 1 }; 49 | const b = { id: '1' }; 50 | const equal = shallowEqual(a, b); 51 | expect(equal).toBe(false); 52 | }); 53 | }); 54 | 55 | describe('shallowEqual', () => { 56 | it('returns true if the references point to the same object', () => { 57 | const a = { id: 1 }; 58 | const b = a; 59 | const equal = shallowEqual(a, b); 60 | expect(equal).toBe(true); 61 | }); 62 | }); 63 | 64 | describe('shallowEqual', () => { 65 | it('returns false if one of the objects has props that are not present on the other', () => { 66 | const a = { id: 1 }; 67 | const b = { id: 1, extra: true }; 68 | const c = { id: 1, differentExtra: true }; 69 | const equalAB = shallowEqual(a, b); 70 | expect(equalAB).toBe(false); 71 | const equalBC = shallowEqual(b, c); 72 | expect(equalBC).toBe(false); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-entities", 3 | "version": "1.3.0", 4 | "description": "Simplified app state management for React", 5 | "keywords": [ 6 | "react", 7 | "state", 8 | "library", 9 | "functional", 10 | "hooks" 11 | ], 12 | "repository": "github:arnelenero/react-entities", 13 | "author": "Arnel Enero ", 14 | "license": "MIT", 15 | "homepage": "https://arnelenero.github.io/react-entities/", 16 | "main": "lib/index.js", 17 | "module": "es/index.js", 18 | "typings": "./index.d.ts", 19 | "files": [ 20 | "lib", 21 | "es", 22 | "index.d.ts" 23 | ], 24 | "scripts": { 25 | "build:lib": "cross-env BABEL_ENV=commonjs babel src --out-dir lib --ignore **/__tests__", 26 | "build:es": "babel src --out-dir es --ignore **/__tests__", 27 | "build": "npm run build:lib && npm run build:es", 28 | "clean": "rimraf lib es", 29 | "prepare": "npm run clean && npm test", 30 | "pretest": "npm run build", 31 | "test": "jest", 32 | "test:cov": "npm test -- --coverage && coveralls < coverage/lcov.info", 33 | "test:cov-local": "npm test -- --coverage" 34 | }, 35 | "peerDependencies": { 36 | "react": ">=16.8.0" 37 | }, 38 | "dependencies": { 39 | "@babel/runtime": "^7.9.2" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "^7.8.4", 43 | "@babel/core": "^7.9.0", 44 | "@babel/plugin-proposal-class-properties": "^7.8.3", 45 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5", 46 | "@babel/plugin-transform-runtime": "^7.9.0", 47 | "@babel/preset-env": "^7.9.5", 48 | "@babel/preset-react": "^7.9.4", 49 | "@typescript-eslint/eslint-plugin": "^4.10.0", 50 | "@typescript-eslint/parser": "^4.10.0", 51 | "babel-eslint": "^10.1.0", 52 | "babel-jest": "^24.9.0", 53 | "coveralls": "^3.1.0", 54 | "cross-env": "^5.2.1", 55 | "enzyme": "^3.11.0", 56 | "enzyme-adapter-react-16": "^1.15.2", 57 | "eslint": "^7.16.0", 58 | "eslint-config-react-app": "^6.0.0", 59 | "eslint-plugin-flowtype": "^5.2.0", 60 | "eslint-plugin-import": "^2.22.1", 61 | "eslint-plugin-jsx-a11y": "^6.4.1", 62 | "eslint-plugin-react": "^7.21.5", 63 | "eslint-plugin-react-hooks": "^4.2.0", 64 | "jest": "^24.9.0", 65 | "react": ">=16.8.0", 66 | "react-dom": ">=16.8.0", 67 | "react-test-renderer": "^16.13.1", 68 | "rimraf": "^2.7.1", 69 | "sinon": "^7.5.0" 70 | }, 71 | "jest": { 72 | "setupFilesAfterEnv": [ 73 | "/test-setup.js" 74 | ], 75 | "collectCoverageFrom": [ 76 | "src/**/*.js", 77 | "!src/**/index.js", 78 | "!src/index.js" 79 | ] 80 | }, 81 | "eslintConfig": { 82 | "extends": "react-app" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/__tests__/EntityScope.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import EntityScope from '../EntityScope'; 5 | import useEntity from '../useEntity'; 6 | 7 | describe('EntityScope', () => { 8 | const counter = { 9 | initialState: { value: 0 }, 10 | increment: (entity, logger) => (by = 1) => { 11 | entity.setState({ 12 | value: entity.state.value + by, 13 | }); 14 | if (logger) logger.log(); 15 | }, 16 | }; 17 | 18 | const CounterView = () => { 19 | hookValue = useEntity('counter'); 20 | 21 | return <>; 22 | }; 23 | 24 | let component = null; 25 | let hookValue = null; 26 | let origConsoleError = null; 27 | 28 | beforeAll(() => { 29 | origConsoleError = console.error; 30 | console.error = jest.fn(); 31 | }); 32 | 33 | afterAll(() => { 34 | console.error = origConsoleError; 35 | }); 36 | 37 | afterEach(() => { 38 | if (component.exists()) component.unmount(); 39 | }); 40 | 41 | it('makes the entity available to its component subtree', () => { 42 | component = mount( 43 | 44 |
45 | 46 |
47 |
48 | ); 49 | 50 | expect(hookValue).toBeDefined(); 51 | }); 52 | 53 | it('does not allow access to its scoped entities outside of its subtree', () => { 54 | expect(() => { 55 | component = mount( 56 |
57 | 58 | 59 |
60 |
61 |
62 | ); 63 | }).toThrow(); 64 | }); 65 | 66 | it('inherits entities from an outer EntityScope (if any)', () => { 67 | component = mount( 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | 75 | expect(hookValue).toBeDefined(); 76 | }); 77 | 78 | it('overrides entities that use the same ID as in an outer EntityScope', () => { 79 | let outerHookValue = null; 80 | const OuterView = () => { 81 | outerHookValue = useEntity('counter'); 82 | return <>; 83 | }; 84 | 85 | component = mount( 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | 94 | hookValue[1].increment(); 95 | expect(hookValue[0]).toHaveProperty('value', 1); 96 | expect(outerHookValue[0]).toHaveProperty('value', 0); 97 | }); 98 | 99 | it('supports injecting dependencies to entities', () => { 100 | const silentLogger = { 101 | log: jest.fn(), 102 | }; 103 | component = mount( 104 | 105 |
106 | 107 |
108 |
109 | ); 110 | 111 | hookValue[1].increment(1); 112 | expect(silentLogger.log).toHaveBeenCalled(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type Action = (...args: any[]) => any; 2 | 3 | export interface Actions { 4 | [actions: string]: Action; 5 | } 6 | 7 | export type SubscriberFn = (state: S) => void; 8 | 9 | export type UpdaterFn = (state: S, ...args: any[]) => Partial; 10 | 11 | export type SetStateFn = ( 12 | updates: Partial | UpdaterFn, 13 | ...updaterArgs: any[] 14 | ) => void; 15 | 16 | export interface EntityOptions { 17 | beforeSetState: (state: S, updates: Partial) => void; 18 | } 19 | 20 | export interface Entity { 21 | state: S; 22 | initialState: S; 23 | setState: SetStateFn; 24 | actions: A; 25 | subscribers: SubscriberFn[]; 26 | reset: () => void; 27 | } 28 | 29 | export type ActionComposer = ( 30 | entity: Entity, 31 | deps: D 32 | ) => Action; 33 | 34 | export interface EntityDefinition { 35 | initialState?: S; 36 | options?: EntityOptions; 37 | [key: string]: S | EntityOptions | ActionComposer; 38 | } 39 | 40 | export type EntityHook = ( 41 | selector?: (state: S) => T, 42 | equalityFn?: (a: any, b: any) => boolean 43 | ) => [T, A]; 44 | 45 | /** 46 | * Creates an entity based on the given definition. 47 | * 48 | * @param definition - entity definition 49 | * @param deps - optional dependencies (e.g. service) 50 | */ 51 | export function createEntity( 52 | definition: EntityDefinition, 53 | deps?: D 54 | ): Entity; 55 | 56 | /** 57 | * Propagates entities down to its entire component subtree, 58 | * thereby making them "scoped entities". 59 | */ 60 | export function EntityScope(props: { 61 | entities: { 62 | [id: string]: EntityDefinition | [ EntityDefinition, any ]; 63 | }; 64 | children: React.ReactNode; 65 | }): JSX.Element; 66 | 67 | /** 68 | * Hook that returns a scoped entity's value (or its 69 | * derivative via optional selector) and actions. 70 | * 71 | * @param entityId - the ID of the entity 72 | * @param selector - optional selector function 73 | * @param equalityFn - optional equality test (default: strictEqual) 74 | */ 75 | export function useEntity( 76 | entityId: string, 77 | selector?: (state: any) => S, 78 | equalityFn?: (a: any, b: any) => boolean 79 | ): [S, A]; 80 | 81 | /** 82 | * Creates an entity based on given definition, then returns a 83 | * corresponding entity hook. 84 | * 85 | * This is used in code patterns using unscoped entities. 86 | * 87 | * @param definition - entity definition 88 | * @param deps - optional dependencies (e.g. service) 89 | */ 90 | export function makeEntity( 91 | definition: EntityDefinition, 92 | deps?: D 93 | ): EntityHook; 94 | 95 | /** 96 | * Hook that automatically resets values of all entities 97 | * when the component unmounts. 98 | * 99 | * This is used in code patterns using unscoped entities. 100 | */ 101 | export function useEntityBoundary(): void; 102 | 103 | export function strictEqual(a: any, b: any): boolean; 104 | export function shallowEqual(a: any, b: any): boolean; 105 | 106 | export function selectAll(o: S): S; 107 | export function selectNone(_: S): null; 108 | -------------------------------------------------------------------------------- /src/__tests__/createEntity.test.js: -------------------------------------------------------------------------------- 1 | import createEntity from '../createEntity'; 2 | 3 | describe('createEntity', () => { 4 | it('returns an entity instance', () => { 5 | const counter = createEntity({ initialState: { value: 0 } }); 6 | expect(counter).toBeInstanceOf(Object); 7 | }); 8 | 9 | it('sets the entity `state` value to the `initialState` by default', () => { 10 | const counter = createEntity({ initialState: { value: 0 } }); 11 | expect(counter.state).toHaveProperty('value', 0); 12 | }); 13 | 14 | it('sets the `state` value to `{}` if no `initialState` in definition', () => { 15 | const counter = createEntity({}); 16 | expect(counter.state).toBeInstanceOf(Object); 17 | }); 18 | 19 | it('includes a `setState` function in the entity', () => { 20 | const counter = createEntity({}); 21 | expect(counter.setState).toBeInstanceOf(Function); 22 | }); 23 | 24 | it('enables `setState` to set the entity state', () => { 25 | const counter = createEntity({ initialState: { value: 0 } }); 26 | counter.setState({ value: 1 }); 27 | expect(counter.state).toHaveProperty('value', 1); 28 | }); 29 | 30 | it('enables `setState` to merge changes with current state', () => { 31 | const counter = createEntity({ initialState: { value: 0 } }); 32 | counter.setState({ updated: true }); 33 | expect(counter.state).toHaveProperty('value', 0); 34 | expect(counter.state).toHaveProperty('updated', true); 35 | }); 36 | 37 | it('supports passing an updater function to `setState`', () => { 38 | const counter = createEntity({ initialState: { value: 0 } }); 39 | const increment = (state, by) => { 40 | return { value: state.value + by }; 41 | }; 42 | counter.setState(increment, 1); 43 | expect(counter.state).toHaveProperty('value', 1); 44 | }); 45 | 46 | it('supports multiple arguments to the updater function', () => { 47 | const counter = createEntity({ initialState: { value: 0 } }); 48 | const adjust = (state, upBy, downBy) => { 49 | return { value: state.value + upBy - downBy }; 50 | }; 51 | counter.setState(adjust, 5, 3); 52 | expect(counter.state).toHaveProperty('value', 2); 53 | }); 54 | 55 | it('includes the `initialState` prop (if any) in the entity', () => { 56 | const counter = createEntity({ initialState: { value: 0 } }); 57 | expect(counter).toHaveProperty('initialState'); 58 | expect(counter.initialState).toHaveProperty('value', 0); 59 | }); 60 | 61 | it('includes an `actions` object in the entity to contain action functions', () => { 62 | const counter = createEntity({ 63 | doSomething: () => () => {}, 64 | }); 65 | expect(counter.actions).toBeInstanceOf(Object); 66 | expect(counter.actions.doSomething).toBeInstanceOf(Function); 67 | }); 68 | 69 | it('requires each action definition to be a higher-order function', () => { 70 | expect(() => { 71 | createEntity({ 72 | increment: (entity, by) => { 73 | entity.setState({ value: entity.state.value + by }); 74 | }, 75 | }); 76 | }).toThrow(); 77 | }); 78 | 79 | it('passes a reference to the entity, including its `state` and `setState` to each action', () => { 80 | const counter = createEntity({ 81 | initialState: { value: 0 }, 82 | increment: entity => by => { 83 | entity.setState({ value: entity.state.value + by }); 84 | }, 85 | reset: entity => () => { 86 | entity.setState(entity.initialState); 87 | }, 88 | }); 89 | counter.actions.increment(2); 90 | expect(counter.state).toHaveProperty('value', 2); 91 | }); 92 | 93 | it('includes the `initialState` in the entity reference passed to actions', () => { 94 | const counter = createEntity({ 95 | initialState: { value: 0 }, 96 | increment: entity => by => { 97 | entity.setState({ value: entity.state.value + by }); 98 | }, 99 | reset: entity => () => { 100 | entity.setState(entity.initialState); 101 | }, 102 | }); 103 | counter.actions.increment(2); 104 | counter.actions.reset(); 105 | expect(counter.state).toHaveProperty('value', 0); 106 | }); 107 | 108 | it('includes the `actions` in the entity reference passed to actions', () => { 109 | const counter = createEntity({ 110 | initialState: { value: 0 }, 111 | increment: entity => by => { 112 | entity.setState({ value: entity.state.value + by }); 113 | }, 114 | up: entity => () => { 115 | entity.actions.increment(1); 116 | }, 117 | }); 118 | counter.actions.up(); 119 | expect(counter.state).toHaveProperty('value', 1); 120 | }); 121 | 122 | it('discards non-functions apart from `initialState` and `options` in entity definition', () => { 123 | const counter = createEntity({ extraProp: true }); 124 | expect(counter).not.toHaveProperty('extraProp'); 125 | }); 126 | 127 | it('allows injecting dependencies into the entity', () => { 128 | let cachedValue = 0; 129 | const cacheService = { 130 | save: value => { 131 | cachedValue = value; 132 | }, 133 | }; 134 | const counter = createEntity( 135 | { 136 | initialState: { value: 0 }, 137 | increment: (entity, service) => by => { 138 | entity.setState({ value: entity.state.value + by }); 139 | service.save(entity.state.value); 140 | }, 141 | }, 142 | cacheService 143 | ); 144 | counter.actions.increment(5); 145 | expect(cachedValue).toBe(5); 146 | }); 147 | 148 | it('runs the `beforeSetState` function (if defined in options) prior to each `setState`', () => { 149 | const counter = createEntity({ 150 | options: { 151 | beforeSetState: (_, update) => { 152 | if (update.value < 0) throw new Error('Invalid counter value'); 153 | }, 154 | }, 155 | }); 156 | expect(() => { 157 | counter.setState({ value: -1 }); 158 | }).toThrow(); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /src/__tests__/useEntity.test.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { mount } from 'enzyme'; 4 | 5 | import useEntity from '../useEntity'; 6 | import EntityScope from '../EntityScope'; 7 | import { shallowEqual } from '../utils'; 8 | 9 | describe('useEntity', () => { 10 | const counter = { 11 | initialState: { value: 0, lastUpdated: new Date() }, 12 | increment: entity => (by = 1) => { 13 | entity.setState({ 14 | value: entity.state.value + by, 15 | lastUpdated: new Date(), 16 | }); 17 | }, 18 | touch: entity => () => entity.setState({ lastUpdated: new Date() }), 19 | }; 20 | 21 | const mountContainer = (ChildA, ChildB) => { 22 | component = mount( 23 | 24 | 25 | {ChildB && } 26 | 27 | ); 28 | }; 29 | 30 | const counterView = (selectKey, equalityFn) => { 31 | const CounterView = () => { 32 | const [key, setKey] = useState(selectKey); 33 | const selector = key 34 | ? equalityFn === shallowEqual 35 | ? state => { 36 | return { [key]: state[key] }; 37 | } 38 | : state => state[key] 39 | : undefined; 40 | setSelectKey = setKey; // Allows modifying the selector key from outside 41 | 42 | hookValue = useEntity('counter', selector, equalityFn); 43 | 44 | useEffect(() => { 45 | renderCount++; 46 | }); 47 | 48 | return <>; 49 | }; 50 | 51 | return CounterView; 52 | }; 53 | 54 | const mountCounter = (selectKey, equalityFn) => { 55 | mountContainer(counterView(selectKey, equalityFn)); 56 | }; 57 | 58 | let component = null; 59 | let renderCount = 0; 60 | let hookValue = null; 61 | let setSelectKey = null; 62 | 63 | afterEach(() => { 64 | if (component.exists()) component.unmount(); 65 | }); 66 | 67 | it('returns the tuple [state, actions] of the entity', () => { 68 | mountCounter(); 69 | 70 | expect(hookValue).toBeInstanceOf(Array); 71 | expect(hookValue).toHaveLength(2); 72 | expect(hookValue[0]).toBeInstanceOf(Object); 73 | expect(hookValue[0]).toHaveProperty('value', 0); 74 | expect(hookValue[1]).toBeInstanceOf(Object); 75 | expect(hookValue[1]).toHaveProperty('increment'); 76 | expect(hookValue[1].increment).toBeInstanceOf(Function); 77 | }); 78 | 79 | it('re-renders the component on each change in entity state caused by an action', () => { 80 | mountCounter(); 81 | 82 | const actions = hookValue[1]; 83 | const prevRenderCount = renderCount; 84 | act(() => { 85 | actions.increment(); 86 | }); 87 | expect(renderCount).toBe(prevRenderCount + 1); 88 | }); 89 | 90 | it('always provides the updated entity state to the component', () => { 91 | mountCounter(); 92 | 93 | const actions = hookValue[1]; 94 | act(() => { 95 | actions.increment(); 96 | }); 97 | expect(hookValue[0]).toHaveProperty('value', 1); 98 | }); 99 | 100 | it('subscribes the component to changes in only the relevant fields when using selector', () => { 101 | mountCounter('value'); 102 | 103 | const actions = hookValue[1]; 104 | const prevRenderCount = renderCount; 105 | act(() => { 106 | actions.touch(); 107 | }); 108 | expect(renderCount).toBe(prevRenderCount); 109 | }); 110 | 111 | it('applies the selector (if any) to the entity state provided to the component', () => { 112 | mountCounter('value'); 113 | 114 | const actions = hookValue[1]; 115 | act(() => { 116 | actions.increment(); 117 | }); 118 | expect(hookValue[0]).toBe(1); 119 | }); 120 | 121 | it('updates subscription whenever the selector changes', () => { 122 | mountCounter('value'); 123 | 124 | const actions = hookValue[1]; 125 | act(() => { 126 | actions.increment(); 127 | }); 128 | expect(hookValue[0]).toBe(1); 129 | 130 | act(() => { 131 | setSelectKey('lastUpdated'); 132 | }); 133 | // hook now uses a different selector 134 | expect(hookValue[0]).toBeInstanceOf(Date); 135 | }); 136 | 137 | it('supports shallow equality for subcription when using selector', () => { 138 | mountCounter('value', shallowEqual); 139 | 140 | const actions = hookValue[1]; 141 | const prevRenderCount = renderCount; 142 | act(() => { 143 | actions.touch(); 144 | }); 145 | expect(renderCount).toBe(prevRenderCount); 146 | }); 147 | 148 | it('subscribes all components that use the hook', () => { 149 | let renderCountB = 0; 150 | let hookValueB = null; 151 | const CounterB = () => { 152 | hookValueB = useEntity('counter'); 153 | useEffect(() => { 154 | renderCountB++; 155 | }); 156 | return <>; 157 | }; 158 | 159 | mountContainer(counterView(), CounterB); 160 | 161 | const actions = hookValue[1]; 162 | const prevRenderCount = renderCount; 163 | const prevRenderCountB = renderCountB; 164 | act(() => { 165 | actions.increment(); 166 | }); 167 | expect(renderCount).toBe(prevRenderCount + 1); 168 | expect(hookValue[0]).toHaveProperty('value', 1); 169 | expect(renderCountB).toBe(prevRenderCountB + 1); 170 | expect(hookValueB[0]).toHaveProperty('value', 1); 171 | }); 172 | 173 | it('unsubscribes the component from the entity when it unmounts', () => { 174 | mountCounter(); 175 | 176 | const actions = hookValue[1]; 177 | const prevRenderCount = renderCount; 178 | component.unmount(); 179 | act(() => { 180 | actions.increment(); 181 | }); 182 | expect(renderCount).toBe(prevRenderCount); 183 | }); 184 | 185 | it('throws an error if no entity with the specified ID is found within scope', () => { 186 | const origConsoleError = console.error; 187 | console.error = jest.fn(); 188 | 189 | const BadComponent = () => { 190 | useEntity('notFound'); 191 | }; 192 | expect(() => { 193 | mountContainer(BadComponent); 194 | }).toThrow(); 195 | 196 | console.error = origConsoleError; 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Entities 2 | 3 | [![npm](https://img.shields.io/npm/v/react-entities)](https://www.npmjs.com/package/react-entities) 4 | [![build](https://img.shields.io/travis/arnelenero/react-entities)](https://travis-ci.org/github/arnelenero/react-entities) 5 | [![coverage](https://img.shields.io/coverallsCoverage/github/arnelenero/react-entities)](https://coveralls.io/github/arnelenero/react-entities) 6 | [![license](https://img.shields.io/github/license/arnelenero/react-entities)](https://opensource.org/licenses/MIT) 7 | 8 | React Entities is an ultra-lightweight library that provides simplified state management for React apps. It takes advantage of React Hooks. 9 | 10 | ## Why React Entities? 11 | 12 | Here are some of the benefits of using React Entities: 13 | - No complicated constructs or boilerplate 14 | - No steep learning curve 15 | - Uses plain functions to implement state changes 16 | - Largely unopinionated and flexible syntax 17 | - Full TypeScript support 18 | - Made specifically for React, and built on React Hooks 19 | - Production-grade, well-documented, actively supported 20 | - It's tiny, only about 1 KB (minified + gzipped) 21 | 22 | 23 | ## Setup 24 | 25 | To install: 26 | ``` 27 | npm install react-entities 28 | ``` 29 | 30 | 31 | ## TLDR: Easy as 1-2-3 32 | 33 | __Step 1:__ Create an entity (shared state) 34 | 35 | ```js 36 | /** counter.js **/ 37 | 38 | export const initialState = { 39 | value: 0 40 | }; 41 | 42 | export const increment = counter => by => { 43 | counter.setState({ value: counter.state.value + by }); 44 | }; 45 | 46 | export const decrement = counter => by => { 47 | counter.setState({ value: counter.state.value - by }); 48 | }; 49 | ``` 50 | 51 | __Step 2:__ Add the entity to a scope 52 | 53 | ```jsx 54 | import { EntityScope } from 'react-entities'; 55 | import * as counter from './entities/counter'; // 👈 56 | import * as settings from './entities/settings'; 57 | 58 | const App = () => { 59 | 63 | 64 | 65 | } 66 | ``` 67 | 68 | __Step 3:__ Use the entity in components 69 | 70 | ```jsx 71 | import { useEntity } from 'react-entities'; 72 | 73 | export const CounterView = () => { 74 | const [counter, { increment, decrement }] = useEntity('counter'); 75 | 76 | return ( 77 | <> 78 |
{counter.value}
79 | 80 | 81 | 82 | ); 83 | }; 84 | ``` 85 | 86 | It's that simple! 87 | 88 | 89 | ## What is an Entity? 90 | 91 | An _entity_ is a single-concern data object whose _state_ can be bound to any number of components in the app as a "shared" state. Once bound to a component, an entity's state acts like local state, i.e. it causes the component to update on every change. 92 | 93 | Apart from state, each entity would also have _actions_, which are just normal functions that make changes to the entity's state. 94 | 95 | 96 | ## Creating an Entity 97 | 98 | A typical entity definition would be a regular module that exports an initial state and several action functions. Here's a simple example: 99 | 100 | **entities/counter.js** 101 | ```js 102 | export const initialState = { 103 | value: 0 104 | }; 105 | 106 | export const increment = counter => by => { 107 | counter.setState({ value: counter.state.value + by }); 108 | }; 109 | 110 | export const decrement = counter => by => { 111 | counter.setState({ value: counter.state.value - by }); 112 | }; 113 | ``` 114 | 115 |
116 | TypeScript version
117 | 118 | **entities/counter.ts** 119 | ```ts 120 | import { Entity } from 'react-entities'; 121 | 122 | /** Types **/ 123 | 124 | export interface Counter { // 👈 125 | value: number; 126 | }; 127 | 128 | export interface CounterActions { 129 | increment: (by: number) => void; 130 | decrement: (by: number) => void; 131 | }; 132 | 133 | export type CounterEntity = Entity; // 👈 134 | 135 | /** Implementation **/ 136 | 137 | // 👇 138 | export const initialState: Counter = { 139 | value: 0 140 | }; 141 | // 👇 142 | export const increment = (counter: CounterEntity) => (by: number) => { 143 | counter.setState({ value: counter.state.value + by }); 144 | }; 145 | // 👇 146 | export const decrement = (counter: CounterEntity) => (by: number) => { 147 | counter.setState({ value: counter.state.value - by }); 148 | }; 149 | ``` 150 | 151 |
152 | 153 | ### Defining the initial state 154 | 155 | The `initialState` defines the default state of the entity. This should always be an object. Since it's optional, it defaults to `{}`. 156 | 157 | ### Defining actions 158 | 159 | In the example above, `increment` and `decrement` are both actions. Although actions are ultimately just normal functions, notice that they are defined using _higher-order functions_, or _composers_. This enables passing an entity reference to the action in its definition. 160 | 161 | This is the basic form of an action definition: 162 | ```js 163 | (entity) => (...arguments) => {} 164 | ``` 165 | 166 | Within the action function, we can use the `state` property of the entity to refer to its current state. To make any changes to its state, we can use its `setState()` function. **Do not** directly mutate the `state` object. 167 | 168 | The function `setState()` has the following familiar form: 169 | ```js 170 | entity.setState(updates) 171 | ``` 172 | where `updates` is an object whose properties are __shallowly merged__ with the current state, thus overriding the old values. 173 | 174 | 175 | ## Adding an Entity to a Scope 176 | 177 | Before we can access an entity in our components, we just need to attach it to a _scope_. The `` component propagates entities down its entire component subtree. 178 | 179 | **App.js** 180 | ```jsx 181 | import { EntityScope } from 'react-entities'; 182 | import * as counter from './entities/counter'; // 👈 183 | import * as settings from './entities/settings'; 184 | 185 | const App = () => { 186 |
187 | 188 | 192 | 193 | 194 | 195 |