├── .gitignore
├── .nvmrc
├── .size-limit.js
├── .size.json
├── .travis.yml
├── README.md
├── __tests__
└── index.tsx
├── doczrc.js
├── jest.config.js
├── package.json
├── src
├── index.ts
└── useCallbackState.ts
├── tsconfig.json
├── tslint.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | npm-debug.log*
2 | yarn-debug.log*
3 | yarn-error.log*
4 |
5 | lib-cov
6 | coverage
7 | .nyc_output
8 |
9 | dist
10 | node_modules/
11 |
12 | .eslintcache
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 10
--------------------------------------------------------------------------------
/.size-limit.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | path: 'dist/es2015/index.js',
4 | limit: '0.2 KB',
5 | },
6 | ];
7 |
--------------------------------------------------------------------------------
/.size.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "dist/es2015/index.js",
4 | "passed": true,
5 | "size": 152
6 | }
7 | ]
8 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '10'
4 | cache: yarn
5 | script:
6 | - yarn
7 | - yarn test:ci
8 | - codecov
9 | notifications:
10 | email: true
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
🤙 use-callback-state 📞
3 |
4 | The same `useState` but it will callback: 📞 Hello! Value is going to change!
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ---
18 |
19 | - reports when state got updated
20 | - controls what could be set to the state
21 | - helps with transformations
22 |
23 | `useState` is about storing a variable, and changing it. However what if not everything could be set, and what if you have to react on state change?
24 |
25 | ## Why?
26 |
27 | - to react on state change in the **same tick**, not **after** as usual, causing potential state tearing and inconsistency.
28 | - for input value validation or transformation
29 |
30 | ## useReducer
31 |
32 | - `useCallbackState` is quite similar to `useReducer`, it receives the old `state` and the `new`, producing the `result`. Use reducer does the same, and `action` could be action. However, you can't replace reducer, while `callback` in `useCallbackStart` would always refer to a latest version.
33 |
34 | ## Control
35 |
36 | For state validation
37 |
38 | ```js
39 | import { useCallbackState } from 'use-callback-state';
40 | const [state, setState] = useCallbackState(
41 | 2,
42 | // allow only even numbers
43 | (newState, oldState) => (newState % 2 ? oldState : newState)
44 | );
45 |
46 | state == 2;
47 |
48 | setState(3);
49 | state == 2; // 3 is odd number, the old state value was enforced
50 |
51 | setState(4);
52 | state == 4;
53 | ```
54 |
55 | For form values management
56 |
57 | ```js
58 | import { useCallbackState } from 'use-callback-state';
59 | const [value, setValue, forceSetValue] = useCallbackState('John Doe', event => event.target.current);
60 | return ;
61 | ```
62 |
63 | ## Report
64 |
65 | ```js
66 | const [state, setState] = useCallbackState(
67 | 42,
68 | newState => {
69 | onValueChange(newState);
70 | } // not returning anything means "nothing transformed"
71 | );
72 |
73 | setState(10);
74 | // call onValueChange(10)
75 | ```
76 |
77 | alternative
78 |
79 | ```js
80 | const [state, setState] = useState(42);
81 | useEffect(() => onValueChange(state), [state]);
82 |
83 | // call onValueChange(42) (did you want it?)
84 | setState(10);
85 | // call onValueChange(10)
86 | ```
87 |
88 | ## State separation
89 |
90 | One state is "public" - what would be reported to the parent component, and another is "internal" - what user would see.
91 | A good example - a `DataInput` where "public value" could be `undefined` if it's not valid
92 |
93 | ```js
94 | const [publicState, setPublicState] = useCallbackState(initialValue, onValueChange);
95 |
96 | const [internalState, setInternalState] = useState(publicState);
97 |
98 | useEffect(() => {
99 | setPublicState(isValid(internalState) ? internalState : undefined);
100 | }, [internalState]);
101 |
102 | return (
103 | <>
104 |
105 | setInternalState(e.target.value)} />
106 | >
107 | );
108 | ```
109 |
110 | # See also
111 |
112 | - [use-callback-ref](https://github.com/theKashey/use-callback-ref) - the same `useRef` but it will callback.
113 |
114 | # License
115 |
116 | MIT
117 |
--------------------------------------------------------------------------------
/__tests__/index.tsx:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react-hooks';
2 |
3 | import { ChangeEvent } from 'react';
4 | import { useCallbackState } from '../src';
5 |
6 | describe('Direct state', () => {
7 | it('Controls the state', () => {
8 | const spy = jest.fn();
9 | const { result } = renderHook(() =>
10 | useCallbackState(42, (newState, oldState) => {
11 | spy(newState, oldState);
12 | return newState + 1;
13 | })
14 | );
15 |
16 | expect(result.current[0]).toBe(42);
17 | expect(spy).not.toHaveBeenCalled();
18 |
19 | act(() => result.current[1](10));
20 |
21 | expect(result.current[0]).toBe(11);
22 | expect(spy).toHaveBeenCalledWith(10, 42);
23 |
24 | act(() => result.current[1](10, true));
25 | });
26 |
27 | it('Even Odd', () => {
28 | const spy = jest.fn();
29 | const { result } = renderHook(() =>
30 | useCallbackState(2, (newState, oldState) => {
31 | spy(newState, oldState);
32 | return newState % 2 ? oldState : newState;
33 | })
34 | );
35 |
36 | act(() => result.current[1](2));
37 |
38 | expect(result.current[0]).toBe(2);
39 | expect(spy).toHaveBeenCalledWith(2, 2);
40 |
41 | act(() => result.current[1](3));
42 |
43 | expect(result.current[0]).toBe(2);
44 | expect(spy).toHaveBeenCalledWith(3, 2);
45 |
46 | act(() => result.current[1](4));
47 |
48 | expect(result.current[0]).toBe(4);
49 | expect(spy).toHaveBeenCalledWith(4, 2);
50 | });
51 |
52 | it('Replace state', () => {
53 | let cb: any = () => undefined;
54 | const { result } = renderHook(() => useCallbackState(42, newState => cb(newState)));
55 |
56 | expect(result.current[0]).toBe(42);
57 |
58 | act(() => result.current[1](1));
59 | expect(result.current[0]).toBe(1);
60 |
61 | act(() => result.current[1](0));
62 | expect(result.current[0]).toBe(0);
63 |
64 | act(() => result.current[1](10));
65 | expect(result.current[0]).toBe(10);
66 |
67 | // replace callback
68 | cb = (newState: number) => newState + 1;
69 | act(() => result.current[1](10));
70 | expect(result.current[0]).toBe(11);
71 | });
72 | });
73 |
74 | describe('Indirect state', () => {
75 | it('number to string', () => {
76 | const spy = jest.fn();
77 | const { result } = renderHook(() =>
78 | useCallbackState('-', (newState: number, oldState) => {
79 | spy(newState, oldState);
80 | return oldState + String(newState);
81 | })
82 | );
83 |
84 | expect(result.current[0]).toBe('-');
85 | expect(spy).not.toHaveBeenCalled();
86 |
87 | act(() => result.current[1](1));
88 |
89 | expect(result.current[0]).toBe('-1');
90 | expect(spy).toHaveBeenCalledWith(1, '-');
91 |
92 | act(() => result.current[1](0, false));
93 | expect(result.current[0]).toBe('-10');
94 | act(() => result.current[2]('-1'));
95 |
96 | expect(result.current[0]).toBe('-1');
97 | });
98 |
99 | it('should not fail TS', () => {
100 | renderHook(() =>
101 | useCallbackState('-', (event: ChangeEvent) => {
102 | return event.target.value;
103 | })
104 | );
105 |
106 | // we are checking TS
107 | expect(1).toBe(1);
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/doczrc.js:
--------------------------------------------------------------------------------
1 | export default {
2 | typescript: true,
3 | title: 'Docz',
4 | menu: ['Getting Started', 'Components'],
5 | };
6 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | };
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-callback-state",
3 | "version": "1.2.0",
4 | "description": "use State, but it will callback",
5 | "main": "dist/es5/index.js",
6 | "module": "dist/es2015/index.js",
7 | "types": "dist/es5/index.d.ts",
8 | "author": "Anton Korzunov ",
9 | "license": "MIT",
10 | "engines": {
11 | "node": ">=10"
12 | },
13 | "scripts": {
14 | "dev": "lib-builder dev",
15 | "test": "jest",
16 | "test:ci": "jest --runInBand --coverage",
17 | "build": "lib-builder build && yarn size:report",
18 | "prepublish": "yarn build",
19 | "release": "yarn build && yarn test",
20 | "size": "npx size-limit",
21 | "size:report": "npx size-limit --json > .size.json",
22 | "lint": "lib-builder lint",
23 | "format": "lib-builder format",
24 | "docz:dev": "docz dev",
25 | "docz:build": "docz build"
26 | },
27 | "peerDependencies": {
28 | "react": "^16.8.0 || ^17.0.0",
29 | "@types/react": "^16.8.0 || ^17.0.0"
30 | },
31 | "peerDependenciesMeta": {
32 | "@types/react": {
33 | "optional": true
34 | }
35 | },
36 | "files": [
37 | "dist"
38 | ],
39 | "keywords": [
40 | "state",
41 | "useState",
42 | "callbaclk"
43 | ],
44 | "repository": "https://github.com/theKashey/use-callback-state",
45 | "husky": {
46 | "hooks": {
47 | "pre-commit": "lint-staged"
48 | }
49 | },
50 | "lint-staged": {
51 | "*.{ts,tsx}": [
52 | "prettier --write",
53 | "tslint --fix",
54 | "git add"
55 | ],
56 | "*.{js,css,json,md}": [
57 | "prettier --write",
58 | "git add"
59 | ]
60 | },
61 | "prettier": {
62 | "printWidth": 120,
63 | "trailingComma": "es5",
64 | "tabWidth": 2,
65 | "semi": true,
66 | "singleQuote": true
67 | },
68 | "dependencies": {
69 | "tslib": "^1.9.3 || ^2.0.0"
70 | },
71 | "devDependencies": {
72 | "@size-limit/preset-small-lib": "^2.1.6",
73 | "@testing-library/react-hooks": "^3.1.1",
74 | "@theuiteam/lib-builder": "^0.0.6",
75 | "react-test-renderer": "^16.11.0"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { useCallbackState } from './useCallbackState';
2 |
--------------------------------------------------------------------------------
/src/useCallbackState.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useDebugValue, useRef, useState } from 'react';
2 |
3 | type IndirectDispatch = (
4 | value: WriteState | ((old: ReadState) => WriteState),
5 | force?: false
6 | ) => void;
7 | type DirectDispatch = (value: ReadState | ((old: ReadState) => ReadState), force?: true) => void;
8 | type ForceDispatch = (value: ReadState | ((old: ReadState) => ReadState)) => void;
9 |
10 | type Dispatch = WriteState extends ReadState
11 | ? DirectDispatch
12 | : IndirectDispatch;
13 |
14 | /**
15 | * useState with integrated onChange callback
16 | * @param initialState - state
17 | * @param {(newState, oldState)=> State} changeCallback onchange callback with ability to stop state change propagation
18 | * @returns [state, setState]
19 | * @example
20 | * allow only EVEN values
21 | * ```
22 | * const [state, setState] = useCallbackState(2, (newState, oldState) => {
23 | * return newState % 2 ? newState : oldState
24 | * }
25 | * ```
26 | *
27 | * @example
28 | * "onChange callback" - return nothing from onValueChange
29 | * ```
30 | * const [state, setState] = useCallbackState("value", newState => onValueChange(newState))
31 | * ```
32 | *
33 | * @example
34 | * Using different read and write states
35 | * ```
36 | * const [value, setValue] = useCallbackState("", (event: ChangeEvent, prevValue) => e.target.value)
37 | * ```
38 | *
39 | * @example
40 | * Skip hook and write directly to the state
41 | * ```js
42 | * setState("anyValue", true); // force update
43 | * ```
44 | */
45 | export function useCallbackState(
46 | initialState: ReadState | (() => ReadState),
47 | changeCallback: (
48 | incomingValue: WriteState,
49 | storedValue: ReadState
50 | ) => WriteState extends ReadState ? ReadState | undefined | void : ReadState
51 | ): [ReadState, Dispatch, ForceDispatch] {
52 | const [state, setState] = useState(initialState);
53 | const callbackRef = useRef(changeCallback);
54 | callbackRef.current = changeCallback;
55 |
56 | const updateState: any = useCallback((newState: any, forceUpdate?: boolean) => {
57 | if (forceUpdate) {
58 | setState(newState);
59 | } else {
60 | setState(oldState => {
61 | const appliedState = typeof newState === 'function' ? newState(oldState) : newState;
62 |
63 | const resultState = callbackRef.current(appliedState, oldState);
64 | return typeof resultState === 'undefined' ? appliedState : resultState;
65 | });
66 | }
67 | }, []);
68 |
69 | useDebugValue(state);
70 |
71 | return [state, updateState, setState];
72 | }
73 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "strictNullChecks": true,
5 | "strictFunctionTypes": true,
6 | "noImplicitThis": true,
7 | "alwaysStrict": true,
8 | "noUnusedLocals": true,
9 | "noUnusedParameters": true,
10 | "noImplicitReturns": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "noImplicitAny": true,
13 | "removeComments": true,
14 | "importHelpers": true,
15 | "target": "es5",
16 | "lib": ["dom", "es5", "scripthost", "es2015.collection", "es2015.symbol", "es2015.iterable", "es2015.promise"],
17 | "jsx": "react"
18 | },
19 | "include": ["src", "__tests__"]
20 | }
21 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-react", "tslint-react-hooks", "tslint-config-prettier"],
3 | "linterOptions": {
4 | "exclude": ["node_modules/**/*.ts"]
5 | },
6 | "rules": {
7 | "no-bitwise": false,
8 | "quotemark": [true, "single", "jsx-double"],
9 | "no-unused-expression": [true, "allow-fast-null-checks"],
10 | "object-literal-sort-keys": false,
11 | "interface-name": false,
12 | "no-var-requires": false,
13 | "jsx-no-lambda": false,
14 | "max-classes-per-file": false,
15 | "jsx-self-close": false
16 | }
17 | }
18 |
--------------------------------------------------------------------------------