├── .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 |
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 | #
2 |
3 | [](https://www.npmjs.com/package/react-entities)
4 | [](https://travis-ci.org/github/arnelenero/react-entities)
5 | [](https://coveralls.io/github/arnelenero/react-entities)
6 | [](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 | increment(1)}>Increment
80 | decrement(1)}>Decrement
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 |
196 | }
197 | ```
198 | (TypeScript version is the same)
199 |
200 | In the example above, `` and all its descendant components have access to the `counter` entity, while `` and ``, and everything else outside the scope, do not.
201 |
202 | We can attach any number of entities to a single ``. The `entities` prop is an object that maps each entity to an ID that can be used to access the entity in components within the scope. In our example above, the entity becomes accessible using the entity ID `'counter'`.
203 |
204 |
205 | ## Using an Entity in Components
206 |
207 | The `useEntity` hook allows us to bind an entity to a component. It takes an entity ID, finds the matching entity within the scope, and returns a pair of values: the entity state and an object containing actions.
208 |
209 | ```js
210 | useEntity(entityId) => [state, actions]
211 | ```
212 |
213 | Here is an example usage:
214 |
215 | **CounterView.js**
216 | ```jsx
217 | import { useEntity } from 'react-entities';
218 |
219 | export const CounterView = () => {
220 | const [counter, { increment, decrement }] = useEntity('counter');
221 |
222 | return (
223 | <>
224 | {counter.value}
225 | increment(1)}>Increment
226 | decrement(1)}>Decrement
227 | >
228 | );
229 | };
230 | ```
231 |
232 |
233 | TypeScript version
234 |
235 | **CounterView.tsx**
236 | ```tsx
237 | import { useEntity } from 'react-entities';
238 | import { Counter, CounterActions } from './entities/counter';
239 |
240 | export const CounterView = () => {
241 | const [counter, { increment, decrement }] =
242 | useEntity('counter');
243 | // 👆 👆
244 | return (
245 | <>
246 | {counter.value}
247 | increment(1)}>Increment
248 | decrement(1)}>Decrement
249 | >
250 | );
251 | };
252 | ```
253 |
254 |
255 |
256 |
257 | ## Recipes
258 |
259 | With the very straightforward, largely unopinionated approach that React Entities brings to managing app state, you have full flexibility to implement things the way you want. It works seamlessly with whatever code architecture you choose for your React app.
260 |
261 | Here we provide some suggested patterns that you may consider for specific scenarios.
262 |
263 | [Binding only relevant data to a component](#binding-only-relevant-data-to-a-component)
264 | [Async actions](#async-actions)
265 | [Calling other actions from an action](#calling-other-actions-from-an-action)
266 | [Injecting dependencies into an entity](#injecting-dependencies-into-an-entity)
267 | [Multiple and nested entity scopes](#multiple-and-nested-entity-scopes)
268 | [Separating "pure" state changes from actions](#separating-pure-state-changes-from-actions)
269 | [Unit testing of entities](#unit-testing-of-entities)
270 |
271 | ### Binding only relevant data to a component
272 |
273 | By default, the `useEntity` hook binds the entire state of the entity to our component. Changes made to any part of this state, even those that are not relevant to the component, would cause a re-render.
274 |
275 | To circumvent this, we can pass a _selector_ function to the hook, as in this example:
276 |
277 | **MainView.js**
278 | ```jsx
279 | import { useEntity } from 'react-entities';
280 |
281 | const MainView = () => {
282 | const [config, { loadConfig }] = useEntity('settings', state => state.config);
283 | // 👆
284 | return (
285 | // . . .
286 | );
287 | };
288 | ```
289 |
290 |
291 | TypeScript version
292 |
293 | **MainView.tsx**
294 | ```tsx
295 | import { useEntity } from 'react-entities';
296 | import { Settings, Config, SettingsActions } from './entities/settings';
297 |
298 | const MainView = () => {
299 | const [config, { loadConfig }] =
300 | useEntity('settings', (state: Settings) => state.config);
301 | // 👆
302 | return (
303 | // . . .
304 | );
305 | ```
306 |
307 |
308 |
309 | Whenever the entity state is updated, the selector function is invoked to provide our component only the relevant data derived from the state. If the result of the selector is equal to the previous result, the component will not re-render.
310 |
311 | The equality check used to compare the current vs. previous selector result is, by default, strict/reference equality, i.e. `===`. We can specify a different equality function if needed. The library provides `shallowEqual` for cases when the selector returns an object with top-level properties derived from the entity state, as in the example below:
312 |
313 | **MainView.js**
314 | ```jsx
315 | import { useEntity, shallowEqual } from 'react-entities';
316 |
317 | const MainView = () => {
318 | const [settings, settingsActions] = useEntity('settings', state => {
319 | return {
320 | theme: state.theme,
321 | enableCountdown: state.featureFlags.countdown
322 | }
323 | }, shallowEqual);
324 | // 👆
325 | return (
326 | // . . .
327 | );
328 | };
329 | ```
330 |
331 |
332 | TypeScript version
333 |
334 | **MainView.tsx**
335 | ```tsx
336 | import { useEntity } from 'react-entities';
337 | import { Settings, Theme, SettingsActions } from './entities/settings';
338 |
339 | interface MainConfig {
340 | theme: Theme;
341 | enableCountdown: boolean;
342 | }
343 |
344 | const MainView = () => {
345 | const [config, { loadConfig }] =
346 | useEntity('settings', (state: Settings) => {
347 | return {
348 | theme: state.theme,
349 | enableCountdown: state.featureFlags.countdown
350 | }
351 | }, shallowEqual);
352 | // 👆
353 | return (
354 | // . . .
355 | );
356 | ```
357 |
358 |
359 |
360 | In case you only require access to actions and not the entity state at all, you can use the selector `selectNone`. This selector always returns `null`.
361 |
362 | **Page.js**
363 | ```jsx
364 | import { useEntity, selectNone } from 'react-entities';
365 |
366 | const Page = () => {
367 | const [, { loadConfig }] = useEntity('settings', selectNone);
368 | // 👆
369 | return (
370 | // . . .
371 | );
372 | };
373 | ```
374 |
375 |
376 | TypeScript version
377 |
378 | **Page.tsx**
379 | ```tsx
380 | import { useEntity, selectNone } from 'react-entities';
381 | import { SettingsActions } from './entities/settings';
382 |
383 | const Page = () => {
384 | const [, { loadConfig }] =
385 | useEntity('settings', selectNone);
386 | // 👆 👆
387 | return (
388 | // . . .
389 | );
390 | ```
391 |
392 |
393 |
394 | [⬆️ Recipes](#recipes)
395 |
396 | ### Async actions
397 |
398 | A typical action makes state changes then immediately terminates. However, since actions are just plain functions, they can contain any operations, including async ones. This gives us the flexibility of implementing things like async data fetches inside actions.
399 |
400 | Here is an example using `async/await` for async action:
401 |
402 | **entities/settings.js**
403 | ```js
404 | import { fetchConfig } from './configService';
405 |
406 | export const initialState = {
407 | loading: false,
408 | config: null
409 | };
410 | // 👇
411 | export const loadConfig = settings => async () => {
412 | settings.setState({ loading: true });
413 |
414 | const res = await fetchConfig();
415 | settings.setState({ loading: false, config: res });
416 | };
417 | ```
418 |
419 |
420 | TypeScript version
421 |
422 | **entities/settings.ts**
423 | ```ts
424 | import { Entity } from 'react-entities';
425 | import { fetchConfig, Config } from './configService';
426 |
427 | /** Types **/
428 |
429 | export interface Settings {
430 | loading: boolean;
431 | config: Config;
432 | };
433 |
434 | export interface SettingsActions {
435 | // 👇
436 | loadConfig: () => Promise;
437 | };
438 |
439 | export type SettingsEntity = Entity;
440 |
441 | /** Implementation **/
442 |
443 | export const initialState: Settings = {
444 | loading: false,
445 | config: null
446 | };
447 | // 👇
448 | export const loadConfig = (settings: SettingsEntity) => async () => {
449 | settings.setState({ loading: true });
450 |
451 | const res = await fetchConfig();
452 | settings.setState({ loading: false, config: res });
453 | };
454 | ```
455 |
456 |
457 |
458 | [⬆️ Recipes](#recipes)
459 |
460 | ### Calling other actions from an action
461 |
462 | An `actions` object is included in the entity reference that is passed onto actions, which allows them to call other actions, as in this example:
463 |
464 | ```js
465 | export const loadAndApplyTheme = ui => async () => {
466 | const res = await fetchTheme();
467 | // 👇
468 | ui.actions.switchTheme(res);
469 | };
470 |
471 | export const switchTheme = ui => theme => {
472 | ui.setState({ theme });
473 | }
474 | ```
475 | __Why not call `switchTheme` directly?__ Remember that the action definition here is a composer function, whereas the final composed action that we invoke at runtime is just a normal function.
476 |
477 | [⬆️ Recipes](#recipes)
478 |
479 | ### Injecting dependencies into an entity
480 |
481 | We can separate reusable code like API calls, common business logic and utilities from our entity code. Instead of importing these _services_ directly into an entity, we can use _dependency injection_ to reduce coupling.
482 |
483 | This is achieved by pairing the entity with its dependencies as a tuple in the `entities` prop of the ``.
484 |
485 | **App.js**
486 | ```jsx
487 | import { EntityScope } from 'react-entities';
488 | import * as counter from './entities/counter';
489 | import * as settings from './entities/settings';
490 | import * as configService from './services/configService';
491 |
492 | const App = () => {
493 |
497 |
498 |
499 | }
500 | ```
501 | (TypeScript version is the same)
502 |
503 | This second argument is passed automatically as extra argument to our action composer function.
504 |
505 | **entities/settings.js**
506 | ```js
507 | export const initialState = {
508 | loading: false,
509 | config: null
510 | };
511 | // 👇
512 | export const loadConfig = (settings, service) => async () => {
513 | settings.setState({ loading: true });
514 |
515 | const res = await service.fetchConfig();
516 | settings.setState({ loading: false, config: res });
517 | };
518 | ```
519 |
520 | In the example above, the `service` would be the `configService` passed via the entity scope.
521 |
522 | [⬆️ Recipes](#recipes)
523 |
524 | ### Multiple and nested entity scopes
525 |
526 | It is simplest to have a single entity scope for all our entities at the top-level component. However, we can have any number of entity scopes, at different levels in our component tree. With nested scopes, entities in the outer scopes are passed down to the inner scopes.
527 |
528 | If you attach the same entity to multiple scopes, each scope will propagate a __separate instance__ of the entity, even if you use the same entity ID across these scopes. When used in a component, that ID then refers to the instance at the nearest scope up the hierarchy.
529 |
530 |
531 | ```jsx
532 | import { EntityScope } from 'react-entities';
533 | import * as counter from './entities/counter';
534 | import * as settings from './entities/settings';
535 |
536 | const App = () => {
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 | }
545 | ```
546 |
547 | (TypeScript version is the same)
548 |
549 | In our example above, `settings` is accessible to both `` and ``, while each of those components will "see" a different `counter`.
550 |
551 | The example is just illustrative, but in practice, multiple scopes are most useful if we do code-splitting. A lazy loaded module can have its own scope for entities that are needed only by that feature.
552 |
553 | [⬆️ Recipes](#recipes)
554 |
555 | ### Separating "pure" state changes from actions
556 |
557 | Using _pure functions_ for updating state has its benefits. Since an entity action can be pretty much any function, it does not automatically prevent _side effects_.
558 |
559 | To allow us to separate "pure" state updates, `setState()` can accept an _updater function_ with the following form:
560 | ```js
561 | updaterFn(state, ...args) => changes
562 | ```
563 | where `state` is the current state and the optional `args` can be any number of arguments. This function returns the changes that will be __shallowly merged__ with the current state by `setState()`.
564 |
565 | The `setState()` call inside actions will then have to be in this form:
566 | ```js
567 | setState(updaterFn, ...updaterArgs)
568 | ```
569 |
570 | In the example below, we can see that this pattern gives us the benefits of pure functions: readability, predictability and reusability among others.
571 |
572 | ```js
573 | export const signIn = (auth, service) => async (email, password) => {
574 | auth.setState(updatePendingFlag, true);
575 |
576 | const { userId, role } = await service.signIn(email, password);
577 | auth.setState(updateAuth, userId, role);
578 | };
579 |
580 | /*** State Updaters ***/
581 |
582 | const updateAuth = (state, userId, role) => {
583 | return { userId, role, isAuthPending: false };
584 | };
585 |
586 | const updatePendingFlag = (state, pending) => {
587 | return { isAuthPending: pending };
588 | };
589 | ```
590 |
591 | Pure state updaters can also be nested to encourage modularity, like in this example:
592 |
593 | ```js
594 | const updateAuth = (state, userId, role) => {
595 | return {
596 | userId,
597 | role,
598 | ...updatePendingFlag(state, false) // 👈
599 | };
600 | };
601 |
602 | const updatePendingFlag = (state, pending) => {
603 | return { isAuthPending: pending };
604 | };
605 | ```
606 |
607 | [⬆️ Recipes](#recipes)
608 |
609 | ### Unit testing of entities
610 |
611 | When we unit test our entities, ideally we would isolate them from the components that use them. For this purpose, we can use the `createEntity` function. It creates an instance of the entity and returns a direct reference to it.
612 |
613 | **counter.test.js**
614 | ```js
615 | import { createEntity } from 'react-entities';
616 | import * as _counter from './counter';
617 |
618 | let counter = null;
619 | beforeAll(() => {
620 | counter = createEntity(_counter); // 👈
621 | });
622 |
623 | beforeEach(() => {
624 | // 👇
625 | counter.reset();
626 | });
627 |
628 | describe('counter', () => {
629 | describe('increment', () => {
630 | it('increments the value of the counter', () => {
631 | // 👇
632 | counter.actions.increment(1);
633 | expect(counter.state.value).toBe(1);
634 | // 👆
635 | });
636 | });
637 | });
638 | ```
639 |
640 | In the example Jest unit test above, `createEntity` gives us the `counter` instance. This way, we are able to trigger an action in `counter.actions`, and then inspect the current state via `counter.state`. It also provides `counter.reset()` to allow us to reset the entity to its `initialState` before each test case is executed.
641 |
642 | [⬆️ Recipes](#recipes)
643 |
--------------------------------------------------------------------------------