├── .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 | Travis 11 | 12 | 13 | bundle size 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 | --------------------------------------------------------------------------------