├── .prettierrc
├── .gitignore
├── todo-list-screenshot.png
├── .vscode
└── settings.json
├── .babelrc
├── src
├── context-easy.css
├── index.js
├── select.js
├── textarea.js
├── checkbox.js
├── checkboxes.js
├── radio-buttons.js
├── input.js
├── context-easy.test.js
└── context-easy.js
├── .eslintrc.json
├── LICENSE
├── talk-abstract.txt
├── package.json
├── README.md
└── hooks.md
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /coverage
3 | /lib
4 | /node_modules
5 | .DS_Store
6 | npm-debug.log*
7 |
--------------------------------------------------------------------------------
/todo-list-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mvolkmann/context-easy/HEAD/todo-list-screenshot.png
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "Dodds",
4 | "Vitullo",
5 | "hypot"
6 | ]
7 | }
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["@babel/preset-env", {"useBuiltIns": false}],
4 | "@babel/preset-react"
5 | ],
6 | "plugins": [
7 | "@babel/plugin-proposal-class-properties",
8 | "@babel/plugin-transform-runtime"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/src/context-easy.css:
--------------------------------------------------------------------------------
1 | .context-easy-checkbox,
2 | .context-easy-radio-buttons {
3 | display: flex;
4 | align-items: center;
5 | }
6 |
7 | .context-easy-checkbox > div {
8 | margin-left: 5px;
9 | }
10 |
11 | .context-easy-checkbox > input[type='checkbox'] {
12 | margin-left: 0;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export {default as Checkbox} from './checkbox';
2 | export {default as Checkboxes} from './checkboxes';
3 | export {default as Input} from './input';
4 | export {default as RadioButtons} from './radio-buttons';
5 | export {default as Select} from './select';
6 | export {default as TextArea} from './textarea';
7 | export * from './context-easy';
8 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true,
5 | "jest": true,
6 | "node": true
7 | },
8 |
9 | "parser": "babel-eslint",
10 |
11 | "parserOptions": {
12 | "ecmaVersion": 7,
13 | "sourceType": "module",
14 | "ecmaFeatures": {
15 | "jsx": true
16 | }
17 | },
18 |
19 | "extends": [
20 | "eslint:recommended",
21 | "plugin:import/recommended",
22 | "plugin:jsx-a11y/recommended",
23 | "plugin:prettier/recommended",
24 | "plugin:react/recommended",
25 | "prettier"
26 | ],
27 |
28 | "plugins": ["html", "jsx-a11y", "prettier", "react"],
29 |
30 | "settings": {
31 | "react": {
32 | "version": "detect"
33 | }
34 | },
35 |
36 | "rules": {
37 | "no-console": "off"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/select.js:
--------------------------------------------------------------------------------
1 | import {bool, func, node, string} from 'prop-types';
2 | import React, {useContext} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | export default function Select(props) {
7 | const context = useContext(EasyContext);
8 |
9 | const handleChange = event => {
10 | const {onChange, path} = props;
11 | const {value} = event.target;
12 | if (path) context.set(path, value);
13 | if (onChange) onChange(event);
14 | };
15 |
16 | const {children, path} = props;
17 |
18 | let value = context.get(path);
19 | if (value === undefined) value = '';
20 |
21 | const selectProps = {...props, value};
22 | delete selectProps.dispatch;
23 |
24 | return (
25 |
28 | );
29 | }
30 |
31 | Select.propTypes = {
32 | children: node,
33 | className: string,
34 | multiple: bool,
35 | onChange: func,
36 | path: string
37 | };
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Mark Volkmann
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/textarea.js:
--------------------------------------------------------------------------------
1 | import {func, string} from 'prop-types';
2 | import React, {useContext, useEffect, useRef} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | export default function TextArea(props) {
7 | const context = useContext(EasyContext);
8 |
9 | const cursorRef = useRef();
10 | const textAreaRef = useRef();
11 |
12 | useEffect(() => {
13 | const {current} = cursorRef;
14 | if (current) {
15 | textAreaRef.current.setSelectionRange(current, current);
16 | }
17 | });
18 |
19 | const handleChange = event => {
20 | const {onChange, path} = props;
21 | const {value} = event.target;
22 |
23 | cursorRef.current = textAreaRef.current.selectionStart;
24 |
25 | if (path) context.set(path, value);
26 | if (onChange) onChange(event);
27 | };
28 |
29 | const {path} = props;
30 | const value = context.get(path);
31 | const textAreaProps = {...props, value};
32 | return (
33 |
34 | );
35 | }
36 |
37 | TextArea.propTypes = {
38 | className: string,
39 | onChange: func,
40 | path: string
41 | };
42 |
--------------------------------------------------------------------------------
/talk-abstract.txt:
--------------------------------------------------------------------------------
1 | "Get Hooked on React!" talk abstract
2 |
3 | Description:
4 |
5 | Hooks enable implementing stateful components
6 | with functions instead of classes.
7 |
8 | The benefits of using hooks include:
9 |
10 | * provides easier ways to work with component state and context
11 | * removes the need to understand the `this` keyword
12 | * removes the need to understand what it means to
13 | bind a function, and when to do it
14 | * supports using "effects" in place of lifecycle methods
15 | which makes it possible to better organize related code
16 | such as adding/removing event listeners and
17 | opening/closing resources
18 | * makes it easier to reuse state logic between multiple components
19 | than using higher-order components and render props
20 | * improves ability for tooling to optimize code because
21 | it is easier to optimize functions than classes
22 |
23 | This talks covers everything you need to know to
24 | get started using hooks in React function components.
25 |
26 | Audience:
27 | web developers using React
28 |
29 | Sample talk video: https://www.youtube.com/watch?v=5kt3urZOg4g
30 |
31 | Level:
32 | Intermediate
33 |
34 | Prerequisite knowledge:
35 | Attendees should be familiar with basic use of React.
36 |
--------------------------------------------------------------------------------
/src/checkbox.js:
--------------------------------------------------------------------------------
1 | import {bool, string} from 'prop-types';
2 | import React, {useContext} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | /**
7 | * This component renders a single checkbox.
8 | * The `className` prop specifies addition CSS classes to be added.
9 | * The `disabled` prop is a boolean that specifies
10 | * whether the checkbox should be disabled.
11 | * The `text` prop specifies its label text.
12 | * The `path` prop specifies its state path.
13 | * Specify a `className` prop to enable styling the checkboxes.
14 | */
15 | export default function Checkbox(props) {
16 | const {className, disabled, path, text} = props;
17 |
18 | const context = useContext(EasyContext);
19 | const checked = Boolean(context.get(path));
20 |
21 | const extraProps = {};
22 | const testId = props['data-testid'];
23 | if (testId) extraProps['data-testid'] = testId;
24 |
25 | function handleChange(event) {
26 | if (path) context.set(path, event.target.checked);
27 | }
28 |
29 | return (
30 |
44 | );
45 | }
46 |
47 | Checkbox.propTypes = {
48 | className: string,
49 | 'data-testid': string,
50 | disabled: bool,
51 | text: string.isRequired,
52 | path: string
53 | };
54 |
--------------------------------------------------------------------------------
/src/checkboxes.js:
--------------------------------------------------------------------------------
1 | import {arrayOf, bool, shape, string} from 'prop-types';
2 | import React, {useContext} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | const getName = index => 'cb' + index;
7 |
8 | /**
9 | * This component renders a set of checkboxes.
10 | * The `list` prop specifies the text and state path
11 | * for each checkbox.
12 | * Specify a `className` prop to enable styling the checkboxes.
13 | */
14 | export default function Checkboxes(props) {
15 | const context = useContext(EasyContext);
16 |
17 | function handleChange(text, event) {
18 | const {list} = props;
19 | const {path} = list.find(obj => obj.text === text);
20 | const value = event.target.checked;
21 | if (path) context.set(path, value);
22 | }
23 |
24 | const {className, disabled, list} = props;
25 |
26 | const extraProps = {};
27 | const testId = props['data-testid'];
28 |
29 | const checkboxes = list.map((obj, index) => {
30 | const {text, path} = obj;
31 | const checked = Boolean(context.get(path));
32 | const name = getName(index);
33 | if (testId) extraProps['data-testid'] = testId + '-' + name;
34 | return (
35 |
47 | );
48 | });
49 |
50 | return
{checkboxes}
;
51 | }
52 |
53 | Checkboxes.propTypes = {
54 | className: string,
55 | 'data-testid': string,
56 | disabled: bool,
57 | list: arrayOf(
58 | shape({
59 | text: string.isRequired,
60 | path: string
61 | })
62 | ).isRequired
63 | };
64 |
--------------------------------------------------------------------------------
/src/radio-buttons.js:
--------------------------------------------------------------------------------
1 | import {arrayOf, bool, shape, string} from 'prop-types';
2 | import React, {useContext} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | /**
7 | * This component renders a set of radio buttons.
8 | * The `list` prop specifies the text and value
9 | * for each radio button.
10 | * The `path` prop specifies the state path
11 | * where the value will be stored.
12 | * Specify a `className` prop to enable styling the radio-buttons.
13 | */
14 | export default function RadioButtons(props) {
15 | const context = useContext(EasyContext);
16 |
17 | const handleChange = event => {
18 | const {path} = props;
19 | const {value} = event.target;
20 | if (path) context.set(path, value);
21 | };
22 |
23 | const {className, disabled, list, path} = props;
24 |
25 | const extraProps = {};
26 | const testId = props['data-testid'];
27 |
28 | const value = context.get(path);
29 |
30 | const radioButtons = list.map(obj => {
31 | if (!obj.value) obj.value = obj.text;
32 | if (testId) extraProps['data-testid'] = testId + '-' + obj.value;
33 | return (
34 |
35 |
45 |
46 |
47 | );
48 | });
49 |
50 | return
{radioButtons}
;
51 | }
52 |
53 | RadioButtons.propTypes = {
54 | className: string,
55 | 'data-testid': string,
56 | disabled: bool,
57 | list: arrayOf(
58 | shape({
59 | text: string.isRequired,
60 | value: string
61 | })
62 | ).isRequired,
63 | path: string
64 | };
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "context-easy",
3 | "version": "1.5.0",
4 | "description": "The easiest way to manage state in a React application!",
5 | "keywords": [
6 | "React",
7 | "state",
8 | "context",
9 | "hooks"
10 | ],
11 | "main": "lib/index.js",
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/mvolkmann/context-easy.git"
15 | },
16 | "license": "MIT",
17 | "scripts": {
18 | "build": "babel src -d lib",
19 | "format": "prettier --write 'src/**/*.{js,css}'",
20 | "lint": "eslint --quiet src --ext .js",
21 | "prepublish": "babel src -d lib && cp src/*.css lib",
22 | "prepush": "npm run verify",
23 | "reinstall": "rm -rf node_modules package-lock.json && npm install",
24 | "test": "npm run build && jest",
25 | "verify": "npm-run-all format lint test"
26 | },
27 | "browserslist": [
28 | ">0.25%",
29 | "not dead",
30 | "not ie <= 11",
31 | "not op_mini all"
32 | ],
33 | "jest": {
34 | "verbose": true
35 | },
36 | "dependencies": {
37 | "lodash": "^4.17.14",
38 | "react": "^16.8.6",
39 | "react-dom": "^16.8.6"
40 | },
41 | "devDependencies": {
42 | "@babel/cli": "^7.5.0",
43 | "@babel/plugin-proposal-class-properties": "^7.5.0",
44 | "@babel/plugin-transform-runtime": "^7.5.0",
45 | "@babel/preset-env": "^7.5.4",
46 | "@babel/preset-react": "^7.0.0",
47 | "@testing-library/react": "^8.0.5",
48 | "babel-eslint": "^10.0.2",
49 | "babel-jest": "^24.8.0",
50 | "babel-plugin-transform-runtime": "^6.23.0",
51 | "cross-env": "^5.2.0",
52 | "eslint": "^6.0.1",
53 | "eslint-config-prettier": "^6.0.0",
54 | "eslint-plugin-html": "^6.0.0",
55 | "eslint-plugin-import": "^2.18.0",
56 | "eslint-plugin-jsx-a11y": "^6.2.3",
57 | "eslint-plugin-prettier": "^3.1.0",
58 | "eslint-plugin-react": "^7.14.2",
59 | "husky": "^3.0.0",
60 | "jest": "^24.8.0",
61 | "npm-run-all": "^4.1.5",
62 | "prettier": "^1.18.2",
63 | "react-test-renderer": "^16.8.6"
64 | },
65 | "peerDependencies": {
66 | "react": "^16.8.6",
67 | "react-dom": "^16.8.6"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/input.js:
--------------------------------------------------------------------------------
1 | import {bool, func, number, string} from 'prop-types';
2 | import React, {useContext, useEffect, useRef} from 'react';
3 |
4 | import {EasyContext} from './context-easy';
5 |
6 | export default function Input(props) {
7 | const {autoFocus, onChange, onEnter, onInput, path, type} = props;
8 | const context = useContext(EasyContext);
9 |
10 | const cursorRef = useRef();
11 | const inputRef = useRef();
12 |
13 | useEffect(() => {
14 | const {current} = cursorRef;
15 | if (current) {
16 | inputRef.current.setSelectionRange(current, current);
17 | }
18 | });
19 |
20 | function handleChange(event) {
21 | const {checked, value} = event.target;
22 |
23 | cursorRef.current = inputRef.current.selectionStart;
24 |
25 | let v = value;
26 | if (type === 'checkbox') {
27 | v = checked;
28 | } else if (type === 'number' || type === 'range') {
29 | if (value.length) v = Number(value);
30 | }
31 |
32 | if (path) context.set(path, v);
33 | if (onChange) onChange(event);
34 | }
35 |
36 | let value = context.get(path);
37 |
38 | const isCheckbox = type === 'checkbox';
39 | if (value === undefined) value = isCheckbox ? false : '';
40 |
41 | const propName = isCheckbox ? 'checked' : 'value';
42 | const inputProps = {
43 | autoFocus,
44 | type: 'text',
45 | ...props,
46 | [propName]: value
47 | };
48 |
49 | if (onEnter) {
50 | inputProps.onKeyPress = event => {
51 | if (event.key === 'Enter') onEnter();
52 | };
53 | delete inputProps.onEnter;
54 | }
55 |
56 | if (type === 'range') {
57 | const customOnInput = onInput;
58 | inputProps.onInput = event => {
59 | if (path) context.set(path, Number(event.target.value));
60 | if (customOnInput) customOnInput(event);
61 | };
62 | }
63 |
64 | return ;
65 | }
66 |
67 | Input.propTypes = {
68 | autoFocus: bool,
69 | className: string,
70 | max: number,
71 | min: number,
72 | onChange: func, // called final changes to value
73 | onEnter: func, // called if user presses enter key
74 | onInput: func, // called on every change to value
75 | path: string, // state path that is updated
76 | type: string // type of the HTML input
77 | };
78 |
--------------------------------------------------------------------------------
/src/context-easy.test.js:
--------------------------------------------------------------------------------
1 | import {get} from 'lodash/fp';
2 | import React, {useContext} from 'react';
3 | import {render, cleanup, fireEvent} from '@testing-library/react';
4 | import {EasyContext, EasyProvider} from './context-easy';
5 |
6 | describe('context-easy', () => {
7 | let initialState;
8 |
9 | beforeEach(() => {
10 | initialState = {
11 | foo: {
12 | bar: 2,
13 | baz: [1, 2, 3, 4],
14 | qux: false
15 | }
16 | };
17 | });
18 |
19 | afterEach(cleanup);
20 |
21 | function tester(methodName, methodArgs, expectedValue) {
22 | let context;
23 |
24 | function TestComponent() {
25 | context = useContext(EasyContext);
26 | const doIt = () => context[methodName](...methodArgs);
27 | return ;
28 | }
29 |
30 | EasyProvider.initialized = false; // very important!
31 | const options = {persist: false, validate: true};
32 | const {getByText} = render(
33 |
34 |
35 |
36 | );
37 |
38 | const button = getByText('Click');
39 | fireEvent.click(button);
40 |
41 | // Wait a bit for the state change to complete.
42 | setTimeout(() => {
43 | const [path] = methodArgs;
44 | expect(get(path, context)).toEqual(expectedValue);
45 | }, 100);
46 | }
47 |
48 | test('decrement', () => {
49 | tester('decrement', ['foo.bar'], 1);
50 | });
51 |
52 | test('delete', () => {
53 | tester('delete', ['foo.bar'], undefined);
54 | });
55 |
56 | test('filter', () => {
57 | tester('filter', ['foo.baz', n => n % 2 === 0], [2, 4]);
58 | });
59 |
60 | test('get', () => {
61 | tester('get', ['foo.bar'], 2);
62 | });
63 |
64 | test('increment', () => {
65 | tester('increment', ['foo.bar'], 3);
66 | });
67 |
68 | test('map', () => {
69 | tester('map', ['foo.baz', n => n * 2], [2, 4, 6, 8]);
70 | });
71 |
72 | test('pop', () => {
73 | tester('pop', ['foo.baz'], [1, 2, 3, 4]);
74 | });
75 |
76 | test('push', () => {
77 | tester('push', ['foo.baz', 5, 6], [1, 2, 3, 4, 5, 6]);
78 | });
79 |
80 | test('set', () => {
81 | tester('set', ['foo.bar', 19], 19);
82 | });
83 |
84 | test('toggle found', () => {
85 | tester('toggle', ['foo.qux'], true);
86 | });
87 |
88 | test('toggle not found', () => {
89 | tester('toggle', ['not.found'], true);
90 | });
91 |
92 | test('transform', () => {
93 | tester('transform', ['foo.bar', n => n * 3], 6);
94 | });
95 |
96 | // This tests making multiple updates.
97 | test('multiple', done => {
98 | let context;
99 |
100 | function TestComponent() {
101 | context = useContext(EasyContext);
102 | function doIt() {
103 | context.increment('foo.bar');
104 | context.transform('foo.bar', n => n * 2);
105 | context.filter('foo.baz', n => n > 2);
106 | }
107 | return ;
108 | }
109 |
110 | EasyProvider.initialized = false; // very important!
111 | const options = {validate: true};
112 | const {getByText} = render(
113 |
114 |
115 |
116 | );
117 |
118 | const button = getByText('Click');
119 | fireEvent.click(button);
120 | setTimeout(() => {
121 | expect(get('foo.bar', context)).toBe(6);
122 | expect(get('foo.baz', context)).toEqual([3, 4]);
123 | done();
124 | });
125 | });
126 |
127 | test('options', () => {
128 | let context;
129 |
130 | function TestComponent() {
131 | context = useContext(EasyContext);
132 | function doIt() {
133 | context.increment('foo.bar');
134 | context.filter('foo.baz', n => n > 2);
135 | }
136 | return ;
137 | }
138 |
139 | EasyProvider.initialized = false; // very important!
140 | const options = {
141 | persist: false,
142 | replacerFn: state => state,
143 | reviverFn: state => state,
144 | version: 'some-version'
145 | };
146 | render(
147 |
148 |
149 |
150 | );
151 | });
152 | });
153 |
--------------------------------------------------------------------------------
/src/context-easy.js:
--------------------------------------------------------------------------------
1 | import {throttle} from 'lodash/function';
2 | import {bool, func, node, object, shape, string} from 'prop-types';
3 | import {get, omit, set, update} from 'lodash/fp';
4 | import React, {Component} from 'react';
5 |
6 | import './context-easy.css';
7 |
8 | const MSG_PREFIX = 'easy-context method ';
9 | const STATE_KEY = 'context-easy-state';
10 | const VERSION_KEY = '@contextEasyVersion';
11 |
12 | export const EasyContext = React.createContext();
13 |
14 | const isProd = process.env.NODE_ENV === 'production';
15 |
16 | let initialState = {},
17 | persist,
18 | replacerFn,
19 | reviverFn,
20 | version;
21 |
22 | function copyWithoutFunctions(obj) {
23 | return Object.keys(obj).reduce((acc, key) => {
24 | const value = obj[key];
25 | if (typeof value !== 'function') acc[key] = value;
26 | return acc;
27 | }, {});
28 | }
29 |
30 | let log = (name, state, path, text, ...values) => {
31 | let msg = name + ' ' + path;
32 | if (text) msg += ' ' + text;
33 | console.info('context-easy:', msg, ...values);
34 | console.info(copyWithoutFunctions(state));
35 | };
36 |
37 | const identityFn = state => state;
38 |
39 | /**
40 | * This is called on app startup and
41 | * again each time the browser window is refreshed.
42 | * This function is only exported so it can be accessed from a test.
43 | */
44 | export function loadState() {
45 | const cleanState = replacerFn(initialState);
46 |
47 | if (!persist) return cleanState;
48 |
49 | const {sessionStorage} = window; // not available in tests
50 |
51 | // If the version passed to context-easy does not match the version
52 | // last saved in sessionStorage, assume that the shape of the state
53 | // may have changed and revert to initialState.
54 | const ssVersion = sessionStorage.getItem(VERSION_KEY);
55 | if (String(version) !== ssVersion) {
56 | sessionStorage.setItem(STATE_KEY, JSON.stringify(cleanState));
57 | sessionStorage.setItem(VERSION_KEY, version);
58 | return cleanState;
59 | }
60 |
61 | let json;
62 | try {
63 | json = sessionStorage ? sessionStorage.getItem(STATE_KEY) : null;
64 | if (!json || json === '""') return cleanState;
65 |
66 | const state = JSON.parse(json);
67 | const revived = reviverFn(state);
68 | return revived;
69 | } catch (e) {
70 | return cleanState;
71 | }
72 | }
73 |
74 | const validateArray = (methodName, path, value) => {
75 | if (Array.isArray(value)) return;
76 | throw new Error(
77 | MSG_PREFIX +
78 | methodName +
79 | ' requires an array, but ' +
80 | path +
81 | ' value is ' +
82 | value
83 | );
84 | };
85 |
86 | const validateFunction = (methodName, value) => {
87 | if (typeof value === 'function') return;
88 | throw new Error(
89 | MSG_PREFIX + methodName + ' requires a function, but got ' + value
90 | );
91 | };
92 |
93 | const validateNumber = (methodName, path, value) => {
94 | if (typeof value === 'number') return;
95 | throw new Error(
96 | MSG_PREFIX +
97 | methodName +
98 | ' requires a number, but ' +
99 | path +
100 | ' value is ' +
101 | value
102 | );
103 | };
104 |
105 | const validatePath = (methodName, path) => {
106 | if (typeof path === 'string') return;
107 | throw new Error(
108 | MSG_PREFIX + methodName + ' requires a string path, but got ' + path
109 | );
110 | };
111 |
112 | /*
113 | * The options prop value can be an object with these properties:
114 |
115 | * replacerFn: function that is passed the state before it is saved in
116 | * sessionStorage and returns the state that should actually be saved there;
117 | * can be used to avoid exposing sensitive data
118 | * reviverFn: function that is passed the state after it is retrieved from
119 | * sessionStorage and returns the state that the app should actually use;
120 | * can be used to supply sensitive data that is not in sessionStorage
121 | * persist: optional boolean
122 | * (defaults to true; set to false to not save state to sessionStorage)
123 | * version: a version string that should be changed
124 | * when the shape of the state changes
125 | */
126 | export class EasyProvider extends Component {
127 | static initialized = false;
128 |
129 | static getDerivedStateFromProps(props) {
130 | if (EasyProvider.initialized) return null;
131 | EasyProvider.initialized = true;
132 | return props.initialState;
133 | }
134 |
135 | static propTypes = {
136 | children: node,
137 | initialState: object,
138 | log: bool,
139 | options: shape({
140 | persist: bool,
141 | replacerFn: func,
142 | reviverFn: func,
143 | version: string
144 | }),
145 | validate: bool
146 | };
147 |
148 | static defaultProps = {
149 | options: {}
150 | };
151 |
152 | previousPromise = null;
153 |
154 | state = {
155 | decrement: (path, delta = 1) => {
156 | if (this.shouldValidate) {
157 | validatePath('decrement', path);
158 | validateNumber('decrement', 'delta', delta);
159 | const value = get(path, this.state);
160 | validateNumber('decrement', path, value);
161 | }
162 | return this.performOperation(
163 | 'update',
164 | path,
165 | n => (n || 0) - delta,
166 | () => log && log('decrement', this.state, path, 'by', delta)
167 | );
168 | },
169 |
170 | delete: path => {
171 | if (this.shouldValidate) validatePath('delete', path);
172 | return this.performOperation(
173 | 'omit',
174 | path,
175 | null,
176 | () => log && log('delete', this.state, path)
177 | );
178 | },
179 |
180 | filter: (path, fn) => {
181 | if (this.shouldValidate) {
182 | validatePath('filter', path);
183 | validateArray('filter', path, get(path, this.state));
184 | validateFunction('filter', fn);
185 | }
186 | return this.performOperation(
187 | 'update',
188 | path,
189 | arr => arr.filter(fn),
190 | () => log && log('filter', this.state, path, 'using', fn)
191 | );
192 | },
193 |
194 | get: (path, defaultValue) => {
195 | const value = get(path, this.state);
196 | return value === undefined ? defaultValue : value;
197 | },
198 |
199 | increment: (path, delta = 1) => {
200 | if (this.shouldValidate) {
201 | validatePath('increment', path);
202 | validateNumber('increment', 'delta', delta);
203 | const value = get(path, this.state);
204 | validateNumber('increment', path, value);
205 | }
206 | return this.performOperation(
207 | 'update',
208 | path,
209 | n => (n || 0) + delta,
210 | () => log && log('increment', this.state, path, 'by', delta)
211 | );
212 | },
213 |
214 | // Note that this is a method and is different
215 | // from the `log` function defined above.
216 | log: (label = '') => {
217 | console.info(
218 | 'context-easy:',
219 | label,
220 | 'state =',
221 | copyWithoutFunctions(this.state)
222 | );
223 | },
224 |
225 | map: (path, fn) => {
226 | if (this.shouldValidate) {
227 | validatePath('map', path);
228 | validateArray('map', path, get(path, this.state));
229 | validateFunction('map', fn);
230 | }
231 | return this.performOperation(
232 | 'update',
233 | path,
234 | arr => arr.map(fn),
235 | () => log && log('map', this.state, path, 'using', fn)
236 | );
237 | },
238 |
239 | pop: path => {
240 | if (this.shouldValidate) {
241 | validatePath('pop', path);
242 | validateArray('pop', path, get(path, this.state));
243 | }
244 | return this.performOperation(
245 | 'pop',
246 | path,
247 | null,
248 | () => log && log('pop', this.state, path)
249 | );
250 | },
251 |
252 | push: (path, ...newValues) => {
253 | if (this.shouldValidate) {
254 | validatePath('push', path);
255 | validateArray('push', path, get(path, this.state));
256 | }
257 | return this.performOperation(
258 | 'push',
259 | path,
260 | newValues,
261 | () => log && log('push', this.state, path, 'with', ...newValues)
262 | );
263 | },
264 |
265 | set: (path, value) => {
266 | if (this.shouldValidate) validatePath('set', path);
267 | return this.performOperation(
268 | 'set',
269 | path,
270 | value,
271 | () => log && log('set', this.state, path, 'to', value)
272 | );
273 | },
274 |
275 | toggle: path => {
276 | const value = get(path, this.state);
277 | if (this.shouldValidate) {
278 | validatePath('toggle', path);
279 | const type = typeof value;
280 | if (type !== 'boolean' && type !== 'undefined') {
281 | throw new Error(
282 | MSG_PREFIX +
283 | 'toggle requires a path to a boolean value, but found' +
284 | type
285 | );
286 | }
287 | }
288 | return this.performOperation('toggle', path, () =>
289 | log('toggle', this.state, path, 'to', !value)
290 | );
291 | },
292 |
293 | transform: (path, fn) => {
294 | if (this.shouldValidate) {
295 | validatePath('transform', path);
296 | validateFunction('transform', fn);
297 | }
298 | return this.performOperation(
299 | 'update',
300 | path,
301 | fn,
302 | () => log && log('transform', this.state, path, 'using', fn)
303 | );
304 | }
305 | };
306 |
307 | componentDidMount() {
308 | const {log: shouldLog, options, validate} = this.props;
309 | if (!shouldLog || isProd) log = () => {}; // noop
310 | this.shouldValidate = validate && !isProd;
311 |
312 | ({
313 | initialState = {},
314 | replacerFn = identityFn,
315 | reviverFn = identityFn,
316 | persist = true,
317 | version = null
318 | } = options);
319 |
320 | this.setState(loadState());
321 | }
322 |
323 | performOperation(operation, path, value, callback) {
324 | const waitFor = this.previousPromise;
325 |
326 | // eslint-disable-next-line no-async-promise-executor
327 | this.previousPromise = new Promise(async resolve => {
328 | await waitFor; // pending operation to complete
329 |
330 | let newState;
331 | switch (operation) {
332 | case 'omit':
333 | newState = omit(path, this.state);
334 | break;
335 | case 'pop': {
336 | const currentValue = get(path, this.state);
337 | const {length} = currentValue;
338 | const lastElement = length ? currentValue[length - 1] : null;
339 | newState = set(
340 | path,
341 | currentValue.slice(0, currentValue.length - 1),
342 | this.state
343 | );
344 | resolve(lastElement);
345 | break;
346 | }
347 | case 'push': {
348 | const currentValue = get(path, this.state);
349 | newState = set(path, [...currentValue, ...value], this.state);
350 | break;
351 | }
352 | case 'set':
353 | newState = set(path, value, this.state);
354 | break;
355 | case 'toggle': {
356 | const currentValue = get(path, this.state);
357 | newState = set(path, !currentValue, this.state);
358 | break;
359 | }
360 | case 'update':
361 | newState = update(path, value, this.state);
362 | break;
363 | default:
364 | throw new Error('unhandled operation ' + operation);
365 | }
366 |
367 | this.setState(newState, () => {
368 | if (persist) this.throttledSave();
369 | if (callback) callback();
370 | resolve();
371 | });
372 | });
373 |
374 | return this.previousPromise;
375 | }
376 |
377 | throttledSave = throttle(() => {
378 | const json = JSON.stringify(replacerFn(this.state));
379 | sessionStorage.setItem(STATE_KEY, json);
380 | }, 1000);
381 |
382 | render() {
383 | return (
384 |
385 | {this.props.children}
386 |
387 | );
388 | }
389 | }
390 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # context-easy
2 |
3 | This provides the easiest way to manage state in a React application.
4 | It implements a Context API Provider.
5 | This Provider can manage all the state for a React application
6 | and is highly generic, making it suitable for any application.
7 |
8 | When using Redux to manage application state,
9 | all the state is held in a single store.
10 | This can be thought of like a client-side database
11 | that holds multiple collections of object.
12 | It provides the potential for all components
13 | to have access to any part of the state
14 | and dispatch actions that update any of it.
15 |
16 | This is similar to how REST services generally
17 | have access to entire databases
18 | rather than being restricted to subsets.
19 | The same level of access can be provided
20 | using the Context API.
21 | The Provider implemented by context-easy
22 | does exactly this.
23 |
24 | The easiest way for components to use this
25 | is through the `useContext` hook added in React 16.8.
26 | If you are not yet familiar with React hooks,
27 | read about them at
28 | .
29 | or watch the video at
30 |
31 |
32 | To use context-easy:
33 |
34 | 1. Modify `src/index.js`.
35 |
36 | 2. Import `EasyProvider`.
37 |
38 | ```js
39 | import {EasyProvider} from 'context-easy';
40 | ```
41 |
42 | 3. Define the initial state. For example:
43 |
44 | ```js
45 | const initialState = {
46 | count: 0,
47 | person: {
48 | name: 'Mark',
49 | occupation: 'software developer'
50 | },
51 | size: 'medium'
52 | };
53 | ```
54 |
55 | This could also be imported from a file named `initial-state.js`.
56 |
57 | 4. Change the call to `render` to use `EasyProvider` to wrap the top component, typically `App`.
58 |
59 | ```js
60 | const jsx = (
61 |
62 |
63 |
64 | );
65 | ReactDOM.render(jsx, document.getElementById('root'));
66 | ```
67 |
68 | In function components that need to access and/or modify this state:
69 |
70 | 1. Import the `useContext` hook and `EasyContext`.
71 |
72 | ```js
73 | import React, {useContext} from 'react';
74 | import {EasyContext} from 'context-easy';
75 | ```
76 |
77 | 2. Get the context object inside the function component.
78 |
79 | ```js
80 | const context = useContext(EasyContext);
81 | ```
82 |
83 | 3. Access state from the `context` object. For example:
84 |
85 | ```js
86 | context.person.name;
87 | ```
88 |
89 | 4. Update state properties at specific paths
90 | by calling methods on the `context` object.\
91 | For example, to change the state property at `person.name`,
92 | call `context.set('person.name', 'Mark')`.
93 |
94 | ## Context Object Methods
95 |
96 | The context object currently implements ten methods.
97 |
98 | - `context.decrement(path)`\
99 | This decrements the number at the given path.
100 | An optional second argument specifies the amount
101 | by which to decrement and defaults to one.
102 |
103 | - `context.delete(path)`\
104 | This deletes the property at the given path.
105 |
106 | - `context.filter(path, fn)`\
107 | This replaces the array at the given path with a new array
108 | that is the result of filtering the current elements.
109 | The function provided as the second argument
110 | is called on each array element.
111 | It should return true for elements to be retained
112 | and false for elements to be filtered out.
113 |
114 | - `context.get(path, defaultValue)`\
115 | This returns the value at a given path.
116 | The optional default value is returned if
117 | there is nothing at the path or the value is undefined.
118 |
119 | - `context.increment(path)`\
120 | This increments the number at the given path.
121 | An optional second argument specifies the amount
122 | by which to increment and defaults to one.
123 |
124 | - `context.log(label)`\
125 | This writes the current state to the devtools console.
126 | It outputs "context-easy:", followed by
127 | an optional label that defaults to an empty string,
128 | "state =", and the state object.
129 |
130 | - `context.map(path, fn)`\
131 | This replaces the array at the given path with a new array.
132 | The function provided as the second argument
133 | is passed each array element one at a time.
134 | The new array will contain the return values of each of these calls.
135 |
136 | - `context.pop(path)`\
137 | This replaces the array at the given path with a new array.
138 | The new array is the old array with the last element removed.
139 | The returned promise resolves to the previous last element.
140 |
141 | - `context.push(path, newValue1, newValue2, ...)`\
142 | This replaces the array at the given path with a new array.
143 | The new array starts with all the existing elements
144 | and ends with all the specified new values.
145 |
146 | - `context.set(path, value)`\
147 | This sets the value at the given path to the given value.
148 |
149 | - `context.toggle(path)`\
150 | This toggles the boolean value at the given path.
151 |
152 | - `context.transform(path, fn)`\
153 | This sets the value at the given path to
154 | the value returned by passing the current value
155 | to the function provided as the second argument.
156 |
157 | ## Re-rendering
158 |
159 | The `useContext` hook subscribes components that call it
160 | to context state updates.
161 | This means that components will be re-rendered
162 | on every context state change.
163 | To only re-render components when specific context state properties are changed,
164 | wrap the component JSX is a call to the `useCallback` hook.
165 |
166 | For example, suppose a component only depends on
167 | the context state properties `count` and `person.name`.
168 | The following code inside a function component
169 | will make it so the component is only re-rendered
170 | when those context state properties change.
171 |
172 | ```js
173 | import React, {useCallback, useContext} from 'react';
174 |
175 | export default SomeComponent() {
176 | const context = useContext(EasyContext);
177 | const {count, person} = context;
178 | const {name} = person;
179 | return useCallback(
180 |
181 | ...component JSX goes here...
182 |
,
183 | [count, name]
184 | );
185 | }
186 | ```
187 |
188 | ## Options
189 |
190 | The `EasyProvider` component accepts props that specify options.
191 |
192 | To log all state changes in the devtools console,
193 | include the `log` prop with no value.
194 |
195 | To validate all method calls made on the context object
196 | and throw an error when they are called incorrectly,
197 | include the `validate` prop with no value.
198 |
199 | These are useful in development,
200 | but typically should not be used in production.
201 | If the `NODE_ENV` environment variable is set to "production",
202 | the `log` and `validate` options are ignored.
203 |
204 | Other options are specified in the `options` prop
205 | whose value is an object that specifies their values.
206 |
207 | The `persist` option
208 | is described in the "SessionStorage" section below.
209 |
210 | The `version` option
211 | is described in the "Versions" section below.
212 |
213 | The `replacerFn` and `reviverFn` options
214 | are described in the "Sensitive Data" section below.
215 |
216 | ## Path Concerns
217 |
218 | When the layout of the state changes, it is necessary
219 | to change state paths throughout the code.
220 | For apps that use a small number of state paths
221 | this is likely not a concern.
222 | For apps that use a large number of state paths,
223 | consider creating a source file that exports
224 | constants for the state paths (perhaps named `path-constants.js`) and
225 | use those when calling `context` methods that require a path.
226 |
227 | For example:
228 |
229 | ```js
230 | // In path-constants.js ...
231 | const GAME_HIGH_SCORE = 'game.statistics.highScore';
232 | const USER_CITY = 'user.address.city';
233 |
234 | // In the source file for a component ...
235 | import {GAME_HIGH_SCORE, USER_CITY} from './path-constants';
236 | ...
237 | context.set(USER_CITY, 'St. Louis');
238 | context.transform(GAME_HIGH_SCORE, score => score + 1);
239 | ```
240 |
241 | With this approach, if the layout of the state changes
242 | it is only necessary to update these constants.
243 |
244 | ## Form Elements Tied to State Paths
245 |
246 | It is common to have `input`, `select`, and `textarea` elements
247 | with `onChange` handlers that get their value from `event.target.value`
248 | and update a specific state path.
249 | An alternative is to use the provided
250 | `Input`, `Select`, and `TextArea`, `Checkbox`, `Checkboxes`,
251 | and `RadioButtons` components as follows:
252 |
253 | ### Input
254 |
255 | HTML `input` elements can be replaced by the `Input` component.
256 | For example:
257 |
258 | ```js
259 |
260 | ```
261 |
262 | The `type` property defaults to `'text'`,
263 | but can be set to any valid value including `'checkbox'`.
264 |
265 | The value used by the `Input` is the state value at the specified path.
266 | When the user changes the value, this component
267 | updates the value at that path in the state.
268 |
269 | To perform additional processing of changes such as validation,
270 | supply an `onChange` prop whose value is a function.
271 |
272 | ### TextArea
273 |
274 | HTML `textarea` elements can be replaced by the `TextArea` component.
275 | For example:
276 |
277 | ```js
278 |
279 | ```
280 |
281 | ### Select
282 |
283 | HTML `select` elements can be replaced by the `Select` component.
284 | For example:
285 |
286 | ```js
287 |
292 | ```
293 |
294 | If the `option` elements have a `value` attribute, that value
295 | will be used instead of the text inside the `option`.
296 |
297 | ### Checkbox
298 |
299 | For a single checkbox, use the `Checkbox` component.
300 | For example:
301 |
302 | ```js
303 |
304 | ```
305 |
306 | When the checkbox is clicked, the boolean value at the corresponding path
307 | will be toggled between false and true.
308 |
309 | ### Checkboxes
310 |
311 | For a set of checkboxes, use the `Checkboxes` component.
312 | For example:
313 |
314 | ```js
315 |
316 | ```
317 |
318 | where checkboxList is set as follows:
319 |
320 | ```js
321 | const checkboxList = [
322 | {text: 'Red', path: 'color.red'},
323 | {text: 'Green', path: 'color.green'},
324 | {text: 'Blue', path: 'color.blue'}
325 | ];
326 | ```
327 |
328 | When a checkbox is clicked, the boolean value at the corresponding path
329 | will be toggled between false and true.
330 |
331 | ### RadioButtons
332 |
333 | For a set of radio buttons, use the `RadioButtons` component.
334 | For example:
335 |
336 | ```js
337 |
342 | ```
343 |
344 | where `radioButtonList` is set as follows:
345 |
346 | ```js
347 | const radioButtonList = [
348 | {text: 'Chocolate', value: 'choc'},
349 | {text: 'Strawberry', value: 'straw'},
350 | {text: 'Vanilla', value: 'van'}
351 | ];
352 | ```
353 |
354 | When a radio button is clicked, the state property `favorite.flavor`
355 | will be set to the value of that radio button.
356 |
357 | All of these components take a `path` prop
358 | which is used to get the current value of the component
359 | and update the value at that path.
360 |
361 | ## SessionStorage
362 |
363 | Typically React state is lost when users refresh the browser.
364 | To avoid this, `sessionStorage` is used to save all the
365 | context state as a JSON string on every state change.
366 | This is throttled so `sessionStorage` is
367 | not updated more frequently than once per second.
368 | The state in `sessionStorage` is automatically reloaded
369 | into the context state when the browser is refreshed.
370 |
371 | To opt out of this behavior, pass an options object to
372 | `EasyProvider` as follows:
373 |
374 | ```js
375 | const options = {persist: false}; // defaults to true
376 | ...
377 | return (
378 |
379 | ...
380 |
381 | )
382 | ```
383 |
384 | ## Versions
385 |
386 | During development when the shape of the initial state changes, it is
387 | desirable to replace what is in `sessionStorage` with the new initial state.
388 |
389 | One way do to this is to close the browser tab and open a new one.
390 | If this isn't done, the application may not work properly because it
391 | will expect different data than what is in `sessionStorage`.
392 |
393 | A way to force the new initial state to be used is to supply a
394 | version property in the options object passed to `EasyProvider`.
395 | When context-easy sees a new version,
396 | it replaces the data in `sessionStorage` with
397 | the `initialState` value passed to `EasyProvider`.
398 |
399 | ## Sensitive Data
400 |
401 | When the context state contains sensitive data
402 | such as passwords and credit card numbers,
403 | it is a good idea to prevent that data from being
404 | written to `sessionStorage`.
405 |
406 | To do this, add `replacerFn` and `reviverFn` functions
407 | to the options object that is passed to `EasyProvider`.
408 | These functions are similar to the optional `replacer` and `reviver` parameters
409 | used by `JSON.stringify` and `JSON.parse`.
410 | Both are passed a state object.
411 | If they wish to change it in any way,
412 | including deleting, modifying, and adding properties,
413 | they should make a copy of the state object,
414 | modify the copy, and return it.
415 | Consider using the lodash function `deepClone` to create the copy.
416 |
417 | ## Browser Devtools
418 |
419 | A nice feature of Redux is the ability to use redux-devtools.
420 | It supports viewing all the actions that have been dispatched
421 | and the state after each action has been processed.
422 |
423 | It also supports "time travel debugging" which
424 | shows the state of the UI after a selected action.
425 | In truth I rarely use time travel debugging.
426 |
427 | The `log` feature of context-easy outputs a
428 | description of each context method call
429 | and the state after the call.
430 | This is somewhat of a replacement for what redux-devtools provides.
431 |
432 | react-devtools displays the data in a context
433 | when its `Provider` element is selected.
434 | It is updated dynamically when context data changes.
435 |
436 | ## Example app
437 |
438 | The GitHub repository at
439 | provides an example application that uses context-easy.
440 |
--------------------------------------------------------------------------------
/hooks.md:
--------------------------------------------------------------------------------
1 | # React Hooks
2 |
3 | ## Overview
4 |
5 | Hooks are a feature added in React 16.7.0-alpha.0.
6 | They enable implementing stateful components
7 | with functions instead of classes.
8 |
9 | Hooks are expected to be ready for production use
10 | in React 16.7 which should be released in Q1 2019.
11 |
12 | There are no plans to remove existing ways of implementing components.
13 | Hooks simply provide a new way that
14 | most developers will find easier to understand
15 | and is backward-compatible with existing code.
16 |
17 | Components that use hooks can be used together
18 | with class-based components.
19 | Existing apps can choose to gradually incorporate hooks
20 | or never use them.
21 |
22 | Eventually it will be possible to use function components to
23 | do everything that is currently possible with class components.
24 | However, there are some lifecycle methods
25 | (`componentDidCatch` and `getSnapshotBeforeUpdate`)
26 | whose functionality cannot yet be implemented using hooks.
27 |
28 | Hooks are currently considered experimental
29 | and the API may still change.
30 |
31 | ## Benefits of Hooks
32 |
33 | Hooks support implementing nearly any component
34 | without creating a class.
35 | Many developers find classes confusing
36 | because they need to understand the `this` keyword,
37 | what it means to `bind` a function, and when to do it.
38 |
39 | Optimizing code that uses functions is easier
40 | than optimizing code that uses classes.
41 | This refers to minifying, hot reloading, and tree shaking.
42 |
43 | Hooks provide easier ways to work with component state and context.
44 |
45 | Hooks make it easier to reuse state logic between multiple components.
46 | In most cases this removes the need
47 | for higher-order components and render props,
48 | both of which require increased levels of code nesting.
49 |
50 | Hooks support using "effects" in place of lifecycle methods.
51 | This makes it possible to better organize related code
52 | such as adding/removing event listeners and
53 | opening/closing resources.
54 |
55 | ## Rules For Hooks
56 |
57 | Hook function names should start with "use".
58 | This convention allows linting rules to check for proper use of hooks
59 | and provides a clue that the function may access state.
60 |
61 | Hook functions should only be called in
62 | function-based components and in custom hooks.
63 |
64 | Hook functions should not be called conditionally.
65 | This means they should not be called in
66 | if/else blocks, loops, or nested functions.
67 | This ensures that for any component,
68 | the same hooks are invoked
69 | in the same order on every render.
70 |
71 | React provides ESLint rules to detect violations of these rules.
72 | See .
73 | This currently implements a single rule named "react-hooks/rules-of-hooks"
74 | that should be configured with a value of "error".
75 | This rule assumes that any function whose name begins
76 | with "use" followed by an uppercase letter is a hook.
77 | It verifies that hooks are only called from
78 | function components (name starts uppercase)
79 | or custom hook functions.
80 | It also verifies that hooks will be called
81 | in the same order on every render.
82 |
83 | A future version of create-react-app will automatically
84 | configure the `react-hooks/rules-of-hooks` ESLint rule.
85 |
86 | ## Provided Hooks
87 |
88 | The hooks provided by React are implemented as functions
89 | that are exported by the `react` package.
90 |
91 | Each of the provided hooks is described in the following sections.
92 | They are somewhat ordered based on
93 | how frequently they are expected to be used.
94 |
95 | ### State Hook
96 |
97 | The `useState` function is a hook that
98 | provides a way to add state to function components.
99 | It takes the initial value of the state and
100 | returns an array containing the current value and a function to change it.
101 | This allows a component to use state without using the `this` keyword.
102 |
103 | For example, the following code can appear
104 | inside a function that defines a component.
105 |
106 | ```js
107 | const [petName, setPetName] = useState('Dasher');
108 | const [petBreed, setPetBreed] = useState('Whippet');
109 | ```
110 |
111 | In this example,
112 | `petName` holds the current value of the state.
113 | `setPetName` is a function that can be called to change the value.
114 | These "set" functions can be passed a new value, or
115 | a function that will be passed the current value and returns the new value.
116 | Calls to them trigger the component to be re-rendered.
117 |
118 | Often the state is a primitive value,
119 | but it can also be an object.
120 | If a state value is an object,
121 | calls to the corresponding set function
122 | must pass an entire new value.
123 | The set functions do not merge the object
124 | passed to them with the current value
125 | as is done by the `Component` `setState` method
126 | which merges the top-level properties.
127 |
128 | Note that the `useState` calls are made
129 | every time the component is rendered.
130 | This allows the component to obtain
131 | the current value for each piece of state.
132 | Initial values are only be applied during the first render.
133 |
134 | Here is a complete component definition that uses the state hook:
135 |
136 | ```js
137 | import React, {useState} from 'react';
138 |
139 | export default function Pet() {
140 | const [petName, setPetName] = useState('Dasher');
141 | const [petBreed, setPetBreed] = useState('Whippet');
142 | const changeBreed = e => setPetBreed(e.target.value);
143 | const changeName = e => setPetName(e.target.value);
144 | return (
145 |
146 |
150 |
151 |
164 |
165 | {petName} is a {petBreed}.
166 |
167 |
168 | );
169 | }
170 | ```
171 |
172 | It's not necessary to understand how this works, but it is interesting.
173 | The state values for a component are stored in a linked list.
174 | Each call to `useState` associates a state value
175 | with a different node in the linked list.
176 | In the example above, `petName` is stored in the first node
177 | and `petBreed` is stored in the second node.
178 |
179 | The `useState` hook adds state to the component that uses it.
180 | The custom hook `useTopState` adds state outside of components
181 | that can be shared with any number of components.
182 | If any component changes the state, all the components that use it are re-rendered.
183 | See .
184 |
185 | ### Effect Hook
186 |
187 | The `useEffect` hook provides an alternative to lifecycle methods like
188 | `componentDidMount`, `componentDidUpdate`, and `componentWillUnmount`
189 | in function components.
190 |
191 | Effects have two phases, setup and cleanup.
192 |
193 | Think of setup as being performed when a class component
194 | would call `componentDidMount` or `componentDidUpdate`,
195 | which is after React updates the DOM.
196 | Examples of setup functionality include
197 | fetching data (ex. calling a REST service),
198 | registering an event listener,
199 | opening a network connection,
200 | and starting a timeout or interval.
201 |
202 | Think of cleanup as being performed when a class component
203 | would call `componentWillUnmount`.
204 | Examples of cleanup functionality include
205 | unregistering an event listener,
206 | closing a network connection,
207 | and clearing a timeout or interval.
208 |
209 | An effect is configured by calling the `useEffect` function
210 | which takes a function that performs the setup.
211 | If no cleanup is needed, this function returns nothing.
212 | If cleanup is needed, this function returns
213 | another function that performs the cleanup.
214 |
215 | For example:
216 |
217 | ```js
218 | useEffect(() => {
219 | console.log('performing setup');
220 | return () => {
221 | console.log('performing cleanup');
222 | };
223 | });
224 | ```
225 |
226 | The `useEffect` function can be called any number of times
227 | inside a function component.
228 | It is typically called once for each distinct kind of effect
229 | rather than combining the code for multiple effects in a single call.
230 |
231 | In the first render of a component,
232 | the order of execution is:
233 |
234 | 1. all the code in the component function
235 | 2. the setup code in all the effects in the order defined
236 |
237 | In subsequent renders, the order of execution is:
238 |
239 | 1. all the code in the component function
240 | 2. the cleanup code in all the effects in the order defined (not reverse order)
241 | 3. the setup code in all the effects in the order defined
242 |
243 | If it is desirable to prevent the setup and cleanup code
244 | from running in every render, supply a second argument to
245 | the `useEffect` function that is an array of variables.
246 | The cleanup and setup steps are only executed again if
247 | any of these variables have changed since the last call.
248 |
249 | One use of an effect is to move focus to a particular input.
250 | This is demonstrated in the "Ref Hook" section later.
251 |
252 | ### Context Hook
253 |
254 | The `useContext` hook provides an alternative way to
255 | consume context state in function components.
256 |
257 | Hooks do not change the way context providers are implemented.
258 | They are still implemented by creating a class that
259 | extends from `React.Component` and renders a `Provider`.
260 | For details, see .
261 |
262 | Suppose a context provider has been implemented
263 | in the component `SomeContext`.
264 | The `useContext` hook can be used in another component
265 | to access its state. For example:
266 |
267 | ```js
268 | import {SomeContext} from './some-context';
269 |
270 | export default MyComponent() {
271 | const context = useContext(SomeContext);
272 | return
{context.someData}
;
273 | }
274 | ```
275 |
276 | The `context` variable is set to an object that provides
277 | read-only access to the state properties of the context
278 | and access to any methods defined on it.
279 | These methods can provide a way for context consumers
280 | to modify the context state.
281 |
282 | Directly setting properties on the `context` variable
283 | affects the local object, but not the context state.
284 | Doing this is not flagged as an error.
285 |
286 | Calling the `useContext` function also
287 | subscribes the function component to context state updates.
288 | Whenever the context state changes,
289 | the function component is re-rendered.
290 |
291 | To avoid re-rendering the component on every context state change,
292 | wrap the returned JSX in a call to `useCallback` described next.
293 |
294 | A great use of the `useContext` hook is in
295 | conjunction with the npm package "context-easy".
296 | See .
297 |
298 | ### Callback Hook
299 |
300 | The `useCallback` hook takes an expression and
301 | an array of variables that affect the result.
302 | It returns a memoized value.
303 | Often the expression is a function.
304 |
305 | This can be used to avoid recreating callback functions
306 | defined in function components every time they are rendered.
307 | Such functions are often used as DOM event handlers.
308 |
309 | For example, consider the difference between these:
310 |
311 | ```js
312 | processInput(e, color, size)}>
313 | processInput(e, color, size), [color, size]}>
314 | ```
315 |
316 | These lines have the same functionality,
317 | but the second line only creates a new function
318 | for the `onChange` prop when the value of
319 | `color` or `size` has changed since the last render.
320 |
321 | Avoiding the creation of new callback functions
322 | allows the React reconciliation process to correctly
323 | determine whether the component needs to be re-rendered.
324 | Avoiding unnecessary renders provides a performance benefit.
325 |
326 | If the callback function does not depend on any variables,
327 | pass an empty array for the second argument.
328 | This causes `useCallback` to always return the same function.
329 | If the second argument is omitted,
330 | a new function will be returned on every call
331 | which defeats the purpose.
332 |
333 | The `useCallback` hook can also serve as a substitute
334 | for the lifecycle method `shouldComponentUpdate`
335 | available in class components.
336 | For example, suppose `v1` and `v2` are variables
337 | whose values come from calls to `useState` or `useContext`
338 | and these are used in the calculation of JSX to be rendered.
339 | To only calculate new JSX if one or both of them
340 | have changed since the last render,
341 | pass the JSX as the first argument to `useCallback`.
342 |
343 | For example:
344 |
345 | ```js
346 | return useCallback(
component JSX goes here.
, [v1, v2]);
347 | ```
348 |
349 | ### Memo Hook
350 |
351 | The `useMemo` hook takes a function and
352 | an array of variables that affect the result.
353 | It memoizes the function and returns its current result.
354 |
355 | For example, suppose `x` and `y` are variables
356 | whose values come from calls to `useState` or `useContext`
357 | and we need to compute a value based on these.
358 | The following code reuses the previous result
359 | if the values of `x` and `y` have not changed.
360 |
361 | ```js
362 | const hypot = useMemo(
363 | () => {
364 | console.log('calculating hypotenuse');
365 | return Math.sqrt(x * x + y * y);
366 | },
367 | [x, y]
368 | );
369 | ```
370 |
371 | This only remembers the result for the last set of input values.
372 | It does not store all past unique calculations.
373 |
374 | Note the difference between `useCallback` and `useMemo`.
375 | While both provide memoization,
376 | `useCallback` returns a value (which could be a function) and
377 | `useMemo` returns the result of calling a function.
378 |
379 | ### React.memo function
380 |
381 | The `React.memo` function, not a hook, was added in React 16.6.
382 | It memoizes a function component so
383 | it is only re-rendered if at least one of its props has changed.
384 | This does what class components do when they
385 | extend from `PureComponent` instead of `Component`.
386 |
387 | For example, the following code defines a `Percent` component
388 | that renders the percentage a count represents of a total.
389 |
390 | ```js
391 | import React from 'react';
392 |
393 | export default React.memo(({count, total}) => {
394 | console.log('Percent rendering'); // to verify when this happens
395 | return {((count / total) * 100).toFixed(2)}%;
396 | });
397 | ```
398 |
399 | ### Reducer Hook
400 |
401 | The `useReducer` hook supports implementing components
402 | whose state is updated by dispatching actions
403 | that are handled by a reducer function.
404 | It is patterned after Redux.
405 | It takes a reducer function and the initial state.
406 |
407 | Here's an example of a very simple todo app
408 | that uses this hook. It uses Sass for styling.
409 | Note how the `TodoList` component calls `useReducer`
410 | to obtain the `state` and the `dispatch` function.
411 | It calls the `dispatch` function
412 | in its event handling functions.
413 |
414 | 
415 |
416 | #### todo-list.scss
417 |
418 | ```scss
419 | .todo-list {
420 | .delete-btn {
421 | background-color: transparent;
422 | border: none;
423 | color: red;
424 | font-weight: bold;
425 | }
426 |
427 | .done-true {
428 | color: gray;
429 | text-decoration: line-through;
430 | }
431 |
432 | form {
433 | margin-bottom: 10px;
434 | }
435 |
436 | .todo {
437 | margin-bottom: 0;
438 | }
439 | }
440 | ```
441 |
442 | #### todo-list.js
443 |
444 | ```js
445 | import React, {useReducer} from 'react';
446 | import './todo-list.scss';
447 |
448 | const initialState = {
449 | text: '',
450 | todos: []
451 | // objects in this have id, text, and done properties.
452 | };
453 |
454 | let lastId = 0;
455 |
456 | function reducer(state, action) {
457 | const {text, todos} = state;
458 | const {payload, type} = action;
459 | switch (type) {
460 | case 'add-todo': {
461 | const newTodos = todos.concat({id: ++lastId, text, done: false});
462 | return {...state, text: '', todos: newTodos};
463 | }
464 | case 'change-text':
465 | return {...state, text: payload};
466 | case 'delete-todo': {
467 | const id = payload;
468 | const newTodos = todos.filter(todo => todo.id !== id);
469 | return {...state, todos: newTodos};
470 | }
471 | case 'toggle-done': {
472 | const id = payload;
473 | const newTodos = todos.map(todo =>
474 | todo.id === id ? {...todo, done: !todo.done} : todo
475 | );
476 | return {...state, todos: newTodos};
477 | }
478 | default:
479 | return state;
480 | }
481 | }
482 |
483 | export default function TodoList() {
484 | const [state, dispatch] = useReducer(reducer, initialState);
485 |
486 | const handleAdd = useCallback(() => dispatch({type: 'add-todo'}));
487 | const handleDelete = useCallback(id =>
488 | dispatch({type: 'delete-todo', payload: id})
489 | );
490 | const handleSubmit = useCallback(e => e.preventDefault()); // prevents form submit
491 | const handleText = useCallback(e =>
492 | dispatch({type: 'change-text', payload: e.target.value})
493 | );
494 | const handleToggleDone = useCallback(id =>
495 | dispatch({type: 'toggle-done', payload: id})
496 | );
497 |
498 | return (
499 |
525 | );
526 | }
527 | ```
528 |
529 | ### Ref Hook
530 |
531 | The `useRef` hook provides an alternative to using
532 | class component instance variables in function components.
533 | Refs persist across renders.
534 | They differ from capturing data using the `useState` hook in that
535 | changes to their values do not trigger the component to re-render.
536 |
537 | The `useRef` function takes the initial value and returns
538 | an object whose `current` property holds the current value.
539 |
540 | A common use is to capture references to DOM nodes.
541 |
542 | For example, in the Todo app above we can
543 | automatically move focus to the text input.
544 | To do this we need to:
545 |
546 | 1. import the `useEffect` and `useRef` hooks
547 | 2. create a ref inside the function
548 | 3. add an effect to move the focus
549 | 4. set the ref using the `input` element `ref` prop
550 |
551 | ```js
552 | const inputRef = useRef();
553 | useEffect(() => inputRef.current.focus());
554 |
555 | return (
556 | ...
557 | ;
563 | ...
564 | )
565 | ```
566 |
567 | Ref values are not required to be DOM nodes.
568 | For example, suppose we wanted to log the number of todos
569 | that have been deleted every time one is deleted.
570 | To do this we need to:
571 |
572 | 1. create a ref inside the function that holds a number
573 | 2. increment the ref value every time a todo is deleted
574 | 3. log the current value
575 |
576 | ```js
577 | const deleteCountRef = useRef(0); // initial value is zero
578 |
579 | // Modified version of the handleDelete function above.
580 | const handleDelete = id => {
581 | deleteCountRef.current++;
582 | dispatch({type: 'delete-todo', payload: id});
583 | console.log('You have now deleted', deleteCountRef.current, 'todos.');
584 | };
585 | ```
586 |
587 | ### Imperative Methods Hook
588 |
589 | The `useImperativeMethods` hook modifies the instance value
590 | that parent components will see if they obtain a ref
591 | to the current component.
592 |
593 | One use is to add methods to the instance
594 | that parent components can call.
595 | For example, suppose the current component contains multiple inputs.
596 | It could use this hook to add a method to its instance value
597 | that parent components can call to move focus to a specific input.
598 |
599 | ### Layout Effect Hook
600 |
601 | The `useLayoutEffect` hook is used to query and modify the DOM.
602 | It is similar to `useEffect`,
603 | but differs in that the function passed to it
604 | is invoked after every DOM mutation in the component.
605 | DOM modifications are applied synchronously.
606 |
607 | One use for this is to implement animations.
608 |
609 | ### Mutation Effect Hook
610 |
611 | The `useMutationEffect` hook is used to modify the DOM.
612 | It is similar to `useEffect`,
613 | but differs in that the function passed to it
614 | is invoked before any sibling components are updated.
615 | DOM modifications are applied synchronously.
616 |
617 | If it is necessary to also query the DOM,
618 | the `useLayoutEffect` hook should be used instead.
619 |
620 | ## Custom Hooks
621 |
622 | A custom hook is a function whose
623 | name begins with "use" and
624 | calls one more hook functions.
625 | They typically return an array or object
626 | that contains state data and
627 | functions that can be called to modify the state.
628 |
629 | Custom hooks are useful for extracting hook functionality
630 | from a function component so it can be
631 | reused in multiple function components.
632 |
633 | For example, Dan Abramov demonstrated a custom hook
634 | that watches the browser window width.
635 | (See the video link in references.)
636 |
637 | ```js
638 | function useWindowWidth() {
639 | // This maintains the width state for any component that calls this function.
640 | const [width, setWidth] = useState(window.innerWidth);
641 | useEffect(() => {
642 | // setup steps
643 | const handleResize = () => setWidth(window.innerWidth);
644 | windowAddEventListener('resize', handleResize);
645 | return () => {
646 | // cleanup steps
647 | windowRemoveEventListener('resize', handleResize);
648 | };
649 | }, []);
650 | }
651 | ```
652 |
653 | To use this in a function component:
654 |
655 | ```js
656 | const width = useWindowWidth();
657 | ```
658 |
659 | Another example Dan Abramov shared simplifies
660 | associating a state value with a form input.
661 | It assumes the state does not need to be
662 | maintained in an ancestor component.
663 | (See the video link in references.)
664 |
665 | ```js
666 | function useFormInput(initialValue) {
667 | const [value, setValue] = useState(initialValue);
668 | const onChange = e => setValue(e.target.value);
669 | // Returning these values in an object instead of an array
670 | // allows it to be spread into the props of an HTML input.
671 | return {onChange, value};
672 | }
673 | ```
674 |
675 | To use this in a function component that
676 | renders an `input` element for entering a name:
677 |
678 | ```js
679 | const nameProps = useFormInput('');
680 |
681 | return (
682 | ;
683 | );
684 | ```
685 |
686 | ## Third Party Hooks
687 |
688 | The React community is busy creating and sharing additional hooks.
689 | Many of these are listed at .
690 |
691 | ## Conclusion
692 |
693 | Hooks are a great addition to React!
694 | They make implementing components much easier.
695 | They also likely spell the end of
696 | implementing React components with classes.
697 | However, you may not want to use them in production apps
698 | just yet since they are still considered experimental
699 | and their API may change.
700 |
701 | Thanks to Jason Schindler for reviewing this article!
702 |
703 | ## Resources
704 |
705 | "React Today and Tomorrow and 90% Cleaner React" talk at React Conf 2018
706 | by Sophie Alpert, Dan Abramov, and Ryan Florence\
707 |
708 |
709 | "Introducing Hooks" Official Documentation in 8 parts\
710 |
711 |
712 | egghead.io Videos from Kent Dodds\
713 |
714 |
715 | "Everything you need to know about React Hooks" by Carl Vitullo\
716 |
717 |
--------------------------------------------------------------------------------