├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── use-debounced │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.tsx │ ├── test │ │ └── index.test.tsx │ └── tsconfig.json ├── use-last │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.tsx │ ├── test │ │ └── index.test.tsx │ └── tsconfig.json ├── use-optional-state │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.tsx │ ├── test │ │ └── index.test.tsx │ └── tsconfig.json ├── use-presence │ ├── .eslintrc.js │ ├── README.md │ ├── media │ │ └── use-presence-demo.gif │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── usePresence.tsx │ │ └── usePresenceSwitch.tsx │ ├── test │ │ ├── usePresence.test.tsx │ │ └── usePresenceSwitch.test.tsx │ └── tsconfig.json └── use-promised │ ├── .eslintrc.js │ ├── README.md │ ├── package.json │ ├── src │ └── index.tsx │ ├── test │ └── index.test.tsx │ └── tsconfig.json ├── tsconfig.build.json ├── tsconfig.json ├── types └── global.d.ts └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-node@v4 10 | with: 11 | node-version: 22.x 12 | - uses: bahmutov/npm-install@v1 13 | - run: yarn lint 14 | - run: yarn test --ci --coverage --maxWorkers=2 15 | - run: yarn build 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | 6 | node_modules 7 | package-lock.json 8 | yarn.lock 9 | !/yarn.lock 10 | 11 | .yalc 12 | yalc.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jan Amann 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-hooks 2 | 3 | A collection of commonly used hooks for React apps. 4 | 5 | **Packages:** 6 | 7 | - [`use-promised`](./packages/use-promised) 8 | - [`use-optional-state`](./packages/use-optional-state) 9 | - [`use-last`](./packages/use-last) 10 | - [`use-debounced`](./packages/use-debounced) 11 | - [`use-presence`](./packages/use-presence) 12 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "registry": "https://registry.npmjs.org/", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "npmClient": "yarn", 8 | "useWorkspaces": true, 9 | "packages": ["packages/*"] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks", 3 | "private": true, 4 | "devDependencies": { 5 | "lerna": "^3.15.0" 6 | }, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "scripts": { 11 | "lerna": "lerna", 12 | "start": "lerna run start --stream --parallel", 13 | "test": "lerna run test --", 14 | "lint": "lerna run lint --", 15 | "build": "lerna run build", 16 | "prepublish": "lerna run prepublish" 17 | }, 18 | "resolutions": { 19 | "@typescript-eslint/parser": "5.20.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/use-debounced/.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('eslint-config-molindo/setupPlugins'); 2 | 3 | module.exports = { 4 | extends: ['molindo/typescript', 'molindo/react'], 5 | env: { 6 | node: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/use-debounced/README.md: -------------------------------------------------------------------------------- 1 | # use-debounced 2 | 3 | [![Stable release](https://img.shields.io/npm/v/use-debounced.svg)](https://npm.im/use-debounced) 4 | 5 | A React hook to debounce the provided value in render. If `delay` is zero, the value is updated synchronously. 6 | 7 | ## Example 8 | 9 | ```jsx 10 | function DebouncedValue() { 11 | const debouncedValue = useDebounced('Hello', 100); 12 | return

{debouncedValue}

; 13 | } 14 | ``` 15 | 16 | This will initially render an empty text (since `debouncedValue` is `undefined`) and after 100ms the rendered value will be changed to `Hello`. 17 | -------------------------------------------------------------------------------- /packages/use-debounced/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-debounced", 3 | "version": "1.2.0", 4 | "license": "MIT", 5 | "description": "A React hook to debounce the provided value in render.", 6 | "author": "Jan Amann ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-debounced" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", 13 | "build": "tsdx build --tsconfig tsconfig.json", 14 | "test": "tsdx test", 15 | "lint": "eslint src test && tsc --noEmit", 16 | "prepublish": "npm run build" 17 | }, 18 | "main": "dist/index.js", 19 | "module": "dist/use-debounced.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "README.md", 23 | "dist" 24 | ], 25 | "peerDependencies": { 26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 28 | }, 29 | "dependencies": { 30 | "tslib": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^10.0.0", 34 | "@testing-library/react": "^16.1.0", 35 | "@types/react": "^19.0.0", 36 | "@types/react-dom": "^19.0.0", 37 | "eslint": "8.13.0", 38 | "eslint-config-molindo": "^6.0.0", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "tsdx": "^0.14.1", 42 | "typescript": "^4.8.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/use-debounced/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef, useReducer} from 'react'; 2 | 3 | /** 4 | * Debounces the provided value in render. If `delay` 5 | * is zero, the value is updated synchronously. 6 | */ 7 | export default function useDebounced(value: T, delay = 300) { 8 | const isSynchronous = delay === 0; 9 | const [, forceUpdate] = useReducer(() => ({}), {}); 10 | 11 | // We initialize the state intentionally with `undefined`, so that the 12 | // first actual value is only set when the first timer has finished. 13 | const debouncedValueRef = useRef(undefined); 14 | 15 | const returnedValue = isSynchronous ? value : debouncedValueRef.current; 16 | 17 | useEffect(() => { 18 | // We still need to set the debounced value, even if it was returned 19 | // synchronously. When the delay increases, we need to be able to return 20 | // the previous value until the new one is applied. 21 | const timeoutId = setTimeout(() => { 22 | debouncedValueRef.current = value; 23 | 24 | if (!isSynchronous && value !== returnedValue) { 25 | forceUpdate(); 26 | } 27 | }, delay); 28 | 29 | return () => clearTimeout(timeoutId); 30 | }, [delay, isSynchronous, value, returnedValue]); 31 | 32 | return returnedValue; 33 | } 34 | -------------------------------------------------------------------------------- /packages/use-debounced/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, waitFor} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import useDebounced from '../src'; 4 | 5 | function DebouncedValue({delay = 100}) { 6 | const debouncedValue = useDebounced('Hello', delay); 7 | return

{debouncedValue}

; 8 | } 9 | 10 | it('delays the rendering of the provided value', async () => { 11 | const {container} = render(); 12 | expect(container.innerHTML).toBe('

'); 13 | 14 | await waitFor(() => { 15 | expect(container.innerHTML).toBe('

Hello

'); 16 | }); 17 | }); 18 | 19 | it('sets the value synchronously if the delay is zero', () => { 20 | const {container} = render(); 21 | expect(container.innerHTML).toBe('

Hello

'); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/use-debounced/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src", "test", "types", "../../types"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/use-last/.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('eslint-config-molindo/setupPlugins'); 2 | 3 | module.exports = { 4 | extends: ['molindo/typescript', 'molindo/react'], 5 | env: { 6 | node: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/use-last/README.md: -------------------------------------------------------------------------------- 1 | # use-last 2 | 3 | [![Stable release](https://img.shields.io/npm/v/use-last.svg)](https://npm.im/use-last) 4 | 5 | > A React hook to conditionally return the last value that has met a certain criteria. 6 | 7 | This is useful when you have a React component that has a state or prop that changes over time, but you want to conditionally skip some values. 8 | 9 | ## Example 10 | 11 | **Implementation:** 12 | 13 | ```jsx 14 | import useLast from 'use-last'; 15 | 16 | function LastValue({value}) { 17 | const lastDefinedValue = useLast(value); 18 | return

{lastDefinedValue}

; 19 | } 20 | ``` 21 | 22 | **Usage:** 23 | 24 | ```jsx 25 | // Renders "2" 26 | 27 | 28 | // Still renders "2" 29 | 30 | 31 | // Renders "4" 32 | 33 | ``` 34 | 35 | ## Configuration 36 | 37 | By default, `value` is checked for being `!== undefined`. You can provide a second argument to customize this condition: 38 | 39 | ```jsx 40 | import useLast from 'use-last'; 41 | 42 | function LastEvenValue({value}) { 43 | const lastEvenValue = useLast(value, value % 2 === 0); 44 | return

{lastEvenValue}

; 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /packages/use-last/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-last", 3 | "version": "1.2.0", 4 | "license": "MIT", 5 | "description": "A React hook to conditionally return the last value that has met a certain criteria.", 6 | "author": "Jan Amann ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-last" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", 13 | "build": "tsdx build --tsconfig tsconfig.json", 14 | "test": "tsdx test", 15 | "lint": "eslint src test && tsc --noEmit", 16 | "prepublish": "npm run build" 17 | }, 18 | "main": "dist/index.js", 19 | "module": "dist/use-last.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "README.md", 23 | "dist" 24 | ], 25 | "peerDependencies": { 26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 28 | }, 29 | "dependencies": { 30 | "tslib": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^10.0.0", 34 | "@testing-library/react": "^16.1.0", 35 | "@types/react": "^19.0.0", 36 | "@types/react-dom": "^19.0.0", 37 | "eslint": "8.13.0", 38 | "eslint-config-molindo": "^6.0.0", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "tsdx": "^0.14.1", 42 | "typescript": "^4.8.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/use-last/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {useRef} from 'react'; 2 | 3 | /** 4 | * Returns the latest version of `value` that matches a critera. 5 | * By default the value is checked for `!== undefined`. 6 | */ 7 | export default function useLast(value: Value, isValid?: boolean) { 8 | const lastValueRef = useRef(undefined); 9 | 10 | if (isValid === undefined) { 11 | isValid = value !== undefined; 12 | } 13 | 14 | if (isValid) { 15 | lastValueRef.current = value; 16 | } 17 | 18 | return lastValueRef.current; 19 | } 20 | -------------------------------------------------------------------------------- /packages/use-last/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, screen} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import useLast from '../src'; 4 | 5 | function LastEvenValue({value}: {value: number}) { 6 | const evenValue = useLast(value, value % 2 === 0); 7 | return

{evenValue}

; 8 | } 9 | 10 | it('accepts values meeting the condition', () => { 11 | const {rerender} = render(); 12 | screen.getByText('2'); 13 | 14 | rerender(); 15 | screen.getByText('4'); 16 | }); 17 | 18 | it('skips values not meeting the condition', () => { 19 | const {rerender} = render(); 20 | screen.getByText('2'); 21 | 22 | rerender(); 23 | screen.getByText('2'); 24 | 25 | rerender(); 26 | screen.getByText('4'); 27 | }); 28 | 29 | it('skips over undefined values by default', () => { 30 | function LastDefinedValue({value}: {value?: number}) { 31 | const definedValue = useLast(value); 32 | return

{definedValue}

; 33 | } 34 | 35 | const {rerender} = render(); 36 | screen.getByText('2'); 37 | 38 | rerender(); 39 | screen.getByText('2'); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/use-last/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src", "test", "types", "../../types"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/use-optional-state/.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('eslint-config-molindo/setupPlugins'); 2 | 3 | module.exports = { 4 | extends: ['molindo/typescript', 'molindo/react'], 5 | env: { 6 | node: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/use-optional-state/README.md: -------------------------------------------------------------------------------- 1 | # use-optional-state 2 | 3 | [![Stable release](https://img.shields.io/npm/v/use-optional-state.svg)](https://npm.im/use-optional-state) 4 | 5 | A React hook to enable a component state to either be controlled or uncontrolled. 6 | 7 | ## The problem 8 | 9 | [Controlled components](https://reactjs.org/docs/forms.html#controlled-components) are a concept mostly known from form elements. They allow the owner to specify exactly what a component should render and to execute custom logic when the component calls a change handler. 10 | 11 | In contrast to this, there are [uncontrolled components](https://reactjs.org/docs/uncontrolled-components.html) which handle the state internally. 12 | 13 | The tradeoff comes down to controlled components being more flexible in their usage but uncontrolled components being easier to use if the owner is not concerned with the state. Sometimes it's desireable for an owner at least configure an initial value and to potentially [reset the child state later with a `key`](https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key). 14 | 15 | When implementing a component, it's sometimes hard to choose one or the other since there are valid use cases for both approaches. 16 | 17 | ## This solution 18 | 19 | This hook helps you to support both patterns in your components, increasing flexibility while also ensuring ease of use. 20 | 21 | Since the solution can be applied on a per-prop basis, you can also enable this behaviour for multiple props that are orthogonal (e.g. a `` component). 22 | 23 | ## Example 24 | 25 | **Implementation:** 26 | 27 | ```jsx 28 | import useOptionalState from 'use-optional-state'; 29 | 30 | function Expander({ 31 | expanded: controlledExpanded, 32 | initialExpanded = false, 33 | onChange 34 | }) { 35 | const [expanded, setExpanded] = useOptionalState({ 36 | controlledValue: controlledExpanded, 37 | initialValue: initialExpanded, 38 | onChange 39 | }); 40 | 41 | function onToggle() { 42 | setExpanded(!expanded); 43 | } 44 | 45 | return ( 46 | <> 47 | 50 | {expanded &&
{children}
} 51 | 52 | ); 53 | } 54 | ``` 55 | 56 | **Usage:** 57 | 58 | ```jsx 59 | // Controlled 60 | 61 | 62 | // Uncontrolled using the default value for the `initialExpanded` prop 63 | 64 | 65 | // Uncontrolled, but with a change handler if the owner wants to be notified 66 | 67 | ``` 68 | -------------------------------------------------------------------------------- /packages/use-optional-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-optional-state", 3 | "version": "2.2.0", 4 | "license": "MIT", 5 | "description": "A React hook to implement components that support both controlled and uncontrolled props.", 6 | "author": "Jan Amann ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-optional-state" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", 13 | "build": "tsdx build --tsconfig tsconfig.json", 14 | "test": "tsdx test", 15 | "lint": "eslint src test && tsc --noEmit", 16 | "prepublish": "npm run build" 17 | }, 18 | "main": "dist/index.js", 19 | "module": "dist/use-optional-state.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "README.md", 23 | "dist" 24 | ], 25 | "peerDependencies": { 26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 28 | }, 29 | "dependencies": { 30 | "tslib": "^2.0.0", 31 | "use-constant": "^2.0.0" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "devDependencies": { 37 | "@testing-library/dom": "^10.0.0", 38 | "@testing-library/react": "^16.1.0", 39 | "@types/react": "^19.0.0", 40 | "@types/react-dom": "^19.0.0", 41 | "eslint": "8.13.0", 42 | "eslint-config-molindo": "^6.0.0", 43 | "react": "^19.0.0", 44 | "react-dom": "^19.0.0", 45 | "tsdx": "^0.14.1", 46 | "typescript": "^4.8.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/use-optional-state/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useCallback} from 'react'; 2 | import useConstant from 'use-constant'; 3 | 4 | // Controlled 5 | export default function useOptionalState(opts: { 6 | controlledValue: Value; 7 | initialValue?: Value | undefined; 8 | onChange?(value: Value): void; 9 | }): [Value, (value: Value) => void]; 10 | 11 | // Uncontrolled with initial value 12 | export default function useOptionalState(opts: { 13 | controlledValue?: Value | undefined; 14 | initialValue: Value; 15 | onChange?(value: Value): void; 16 | }): [Value | undefined, (value: Value) => void]; 17 | 18 | // Uncontrolled without initial value 19 | export default function useOptionalState(opts: { 20 | controlledValue?: Value | undefined; 21 | initialValue?: Value; 22 | onChange?(value: Value): void; 23 | }): [Value | undefined, (value: Value) => void]; 24 | 25 | /** 26 | * Enables a component state to be either controlled or uncontrolled. 27 | */ 28 | export default function useOptionalState({ 29 | controlledValue, 30 | initialValue, 31 | onChange 32 | }: { 33 | controlledValue?: Value | undefined; 34 | initialValue?: Value | undefined; 35 | onChange?(value: Value): void; 36 | }) { 37 | const isControlled = controlledValue !== undefined; 38 | const initialIsControlled = useConstant(() => isControlled); 39 | const [stateValue, setStateValue] = useState(initialValue); 40 | 41 | if (__DEV__) { 42 | if (initialIsControlled && !isControlled) { 43 | throw new Error( 44 | 'Can not change from controlled to uncontrolled mode. If `undefined` needs to be used for controlled values, please use `null` instead.' 45 | ); 46 | } 47 | 48 | if (!initialIsControlled && isControlled) { 49 | throw new Error( 50 | 'Can not change from uncontrolled to controlled mode. Please supply an initial value other than `undefined` to make the state controlled over its lifetime. If `undefined` needs to be used for controlled values, please use `null` instead.' 51 | ); 52 | } 53 | } 54 | 55 | const value = isControlled ? controlledValue : stateValue; 56 | 57 | const onValueChange = useCallback( 58 | (nextValue: Value) => { 59 | if (!isControlled) setStateValue(nextValue); 60 | if (onChange) onChange(nextValue); 61 | }, 62 | [isControlled, onChange] 63 | ); 64 | 65 | return [value, onValueChange]; 66 | } 67 | -------------------------------------------------------------------------------- /packages/use-optional-state/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {fireEvent, render, screen} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import useOptionalState from '../src'; 4 | 5 | (global as any).__DEV__ = true; 6 | 7 | type Props = { 8 | expanded?: boolean; 9 | initialExpanded?: boolean; 10 | onChange?(expanded: boolean): void; 11 | }; 12 | 13 | function Expander({ 14 | expanded: controlledExpanded, 15 | initialExpanded, 16 | onChange 17 | }: Props) { 18 | const [expanded, setExpanded] = useOptionalState({ 19 | controlledValue: controlledExpanded, 20 | initialValue: initialExpanded, 21 | onChange 22 | }); 23 | 24 | function onToggle() { 25 | setExpanded(!expanded); 26 | } 27 | 28 | return ( 29 | <> 30 | 33 | {expanded &&

Children

} 34 | 35 | ); 36 | } 37 | 38 | it('supports a controlled mode', () => { 39 | const onChange = jest.fn(); 40 | 41 | const {rerender} = render(); 42 | screen.getByText('Children'); 43 | fireEvent.click(screen.getByText('Toggle')); 44 | expect(onChange).toHaveBeenLastCalledWith(false); 45 | 46 | rerender(); 47 | expect(screen.queryByText('Children')).toBe(null); 48 | fireEvent.click(screen.getByText('Toggle')); 49 | expect(onChange).toHaveBeenLastCalledWith(true); 50 | 51 | rerender(); 52 | screen.getByText('Children'); 53 | }); 54 | 55 | it('supports an uncontrolled mode', () => { 56 | const onChange = jest.fn(); 57 | 58 | render(); 59 | screen.getByText('Children'); 60 | fireEvent.click(screen.getByText('Toggle')); 61 | expect(onChange).toHaveBeenLastCalledWith(false); 62 | 63 | expect(screen.queryByText('Children')).toBe(null); 64 | fireEvent.click(screen.getByText('Toggle')); 65 | expect(onChange).toHaveBeenLastCalledWith(true); 66 | screen.getByText('Children'); 67 | }); 68 | 69 | it('supports an uncontrolled mode with no initial value', () => { 70 | const onChange = jest.fn(); 71 | 72 | render(); 73 | expect(screen.queryByText('Children')).toBe(null); 74 | fireEvent.click(screen.getByText('Toggle')); 75 | expect(onChange).toHaveBeenLastCalledWith(true); 76 | 77 | screen.getByText('Children'); 78 | fireEvent.click(screen.getByText('Toggle')); 79 | expect(onChange).toHaveBeenLastCalledWith(false); 80 | expect(screen.queryByText('Children')).toBe(null); 81 | }); 82 | 83 | it('allows to use an initial value without a change handler', () => { 84 | // Maybe the value is read from the DOM directly 85 | render(); 86 | }); 87 | 88 | it('allows using a controlled value without a change handler', () => { 89 | // Forced static value 90 | render(); 91 | }); 92 | 93 | it('uses the controlled value when both a controlled as well as an initial value is provided', () => { 94 | render(); 95 | screen.getByText('Children'); 96 | }); 97 | 98 | it('throws when switching from uncontrolled to controlled mode', () => { 99 | const {rerender} = render(); 100 | 101 | expect(() => rerender()).toThrow( 102 | /Can not change from uncontrolled to controlled mode./ 103 | ); 104 | }); 105 | 106 | it('throws when switching from controlled to uncontrolled mode', () => { 107 | const {rerender} = render(); 108 | 109 | expect(() => rerender()).toThrow( 110 | /Can not change from controlled to uncontrolled mode./ 111 | ); 112 | }); 113 | 114 | /** 115 | * Type signature tests 116 | */ 117 | 118 | function TestTypes() { 119 | const controlled = useOptionalState({ 120 | controlledValue: true 121 | }); 122 | controlled[0].valueOf(); 123 | 124 | const uncontrolledWithInitialValue = useOptionalState({ 125 | initialValue: true 126 | }); 127 | // @ts-expect-error Null-check would be necessary 128 | uncontrolledWithInitialValue[0].valueOf(); 129 | 130 | const uncontrolledWithoutInitialValue = useOptionalState({}); 131 | // @ts-expect-error Null-check would be necessary 132 | uncontrolledWithoutInitialValue[0].valueOf(); 133 | 134 | // Only used for type tests; mark the variables as used 135 | // eslint-disable-next-line no-unused-expressions 136 | [controlled, uncontrolledWithInitialValue, uncontrolledWithoutInitialValue]; 137 | } 138 | 139 | // Expected return type: `[boolean, (value: boolean) => void]` 140 | function Controlled(opts: {controlledValue: boolean; initialValue?: boolean}) { 141 | const [value, setValue] = useOptionalState(opts); 142 | 143 | setValue(true); 144 | return value.valueOf(); 145 | } 146 | 147 | // Expected return type: `[boolean | undefined, (value: boolean) => void]` 148 | // Note that theoretically `undefined` shouldn't be possible here, 149 | // but the types seem to be quite hard to get right. 150 | function UncontrolledWithInitialValue(opts: { 151 | controlledValue?: boolean; 152 | initialValue: boolean; 153 | }) { 154 | const [value, setValue] = useOptionalState(opts); 155 | 156 | setValue(true); 157 | 158 | // @ts-expect-error Null-check would be necessary 159 | return value.valueOf(); 160 | } 161 | 162 | // Expected return type: `[boolean | undefined, (value: boolean) => void]` 163 | function UncontrolledWithoutInitialValue(opts: { 164 | controlledValue?: boolean; 165 | initialValue?: boolean; 166 | }) { 167 | const [value, setValue] = useOptionalState(opts); 168 | 169 | setValue(true); 170 | 171 | // @ts-expect-error Null-check would be necessary 172 | return value.valueOf(); 173 | } 174 | 175 | // Only used for type tests; mark the functions as used 176 | // eslint-disable-next-line no-unused-expressions 177 | [ 178 | TestTypes, 179 | Controlled, 180 | UncontrolledWithInitialValue, 181 | UncontrolledWithoutInitialValue 182 | ]; 183 | -------------------------------------------------------------------------------- /packages/use-optional-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src", "test", "types", "../../types"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/use-presence/.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('eslint-config-molindo/setupPlugins'); 2 | 3 | module.exports = { 4 | extends: ['molindo/typescript', 'molindo/react'], 5 | env: { 6 | node: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/use-presence/README.md: -------------------------------------------------------------------------------- 1 | # use-presence 2 | 3 | [![Stable release](https://img.shields.io/npm/v/use-presence.svg)](https://npm.im/use-presence) 4 | 5 | A 1kb React hook to animate the presence of an element. 6 | 7 | 8 | 9 | [Demo app](https://codesandbox.io/s/usepresence-demo-1u6vq?file=/src/Expander.js) 10 | 11 | ## The problem 12 | 13 | There are two problems that you have to solve when animating the presence of an element: 14 | 15 | 1. During enter animations, you have to render an initial state where the element is hidden and only after this has flushed to the DOM, you can can animate the final state that the element should animate towards. 16 | 2. Exit animations are a bit tricky in React, since this typically means that a component unmounts. However when the component has already unmounted, you can't animate it anymore. A workaround is often to keep the element mounted, but that keeps unnecessary elements around and can hurt accessibility, as hidden interactive elements might still be focusable. 17 | 18 | ## This solution 19 | 20 | This hook provides a lightweight solution where the animating element is only mounted the minimum of time, while making sure the animation is fully visible to the user. The rendering is left to the user to support all kinds of styling solutions. 21 | 22 | ## Example 23 | 24 | ```jsx 25 | import usePresence from 'use-presence'; 26 | 27 | function Expander({children, isOpen, transitionDuration = 500}) { 28 | const {isMounted, isVisible, isAnimating} = usePresence(isOpen, {transitionDuration}); 29 | 30 | if (!isMounted) { 31 | return null; 32 | } 33 | 34 | return ( 35 |
49 | {children} 50 |
51 | ); 52 | } 53 | ``` 54 | 55 | ## API 56 | 57 | ```tsx 58 | const { 59 | /** Should the component be returned from render? */ 60 | isMounted, 61 | /** Should the component have its visible styles applied? */ 62 | isVisible, 63 | /** Is the component either entering or exiting currently? */ 64 | isAnimating, 65 | /** Is the component entering currently? */ 66 | isEntering, 67 | /** Is the component exiting currently? */ 68 | isExiting 69 | } = usePresence( 70 | /** Indicates whether the component that the resulting values will be used upon should be visible to the user. */ 71 | isVisible: boolean, 72 | opts: { 73 | /** Duration in milliseconds used both for enter and exit transitions. */ 74 | transitionDuration: number; 75 | /** Duration in milliseconds used for enter transitions (overrides `transitionDuration` if provided). */ 76 | enterTransitionDuration: number; 77 | /** Duration in milliseconds used for exit transitions (overrides `transitionDuration` if provided). */ 78 | exitTransitionDuration: number; 79 | /** Opt-in to animating the entering of an element if `isVisible` is `true` during the initial mount. */ 80 | initialEnter?: boolean; 81 | } 82 | ) 83 | ``` 84 | 85 | ## `usePresenceSwitch` 86 | 87 | If you have multiple items where only one is visible at a time, you can use the supplemental `usePresenceSwitch` hook to animate the items in and out. Previous items will exit before the next item transitions in. 88 | 89 | ### API 90 | 91 | ```tsx 92 | const { 93 | /** The item that should currently be rendered. */ 94 | mountedItem, 95 | /** Returns all other properties from `usePresence`. */ 96 | ...rest 97 | } = usePresence( 98 | /** The current item that should be visible. If `undefined` is passed, the previous item will animate out. */ 99 | item: ItemType | undefined, 100 | /** See the `opts` argument of `usePresence`. */ 101 | opts: Parameters[1] 102 | ) 103 | ``` 104 | 105 | ### Example 106 | 107 | ```jsx 108 | const tabs = [ 109 | { 110 | title: 'Tab 1', 111 | content: 'Tab 1 content' 112 | }, 113 | { 114 | title: 'Tab 2', 115 | content: 'Tab 2 content' 116 | }, 117 | { 118 | title: 'Tab 3', 119 | content: 'Tab 3 content' 120 | }, 121 | ]; 122 | 123 | function Tabs() { 124 | const [tabIndex, setTabIndex] = useState(0); 125 | 126 | return ( 127 | <> 128 | {tabs.map((tab, index) => ( 129 | 132 | ))} 133 | 134 | {tabs[tabIndex].content} 135 | 136 | 137 | ); 138 | } 139 | 140 | function TabContent({ children, transitionDuration = 500 }) { 141 | const { 142 | isMounted, 143 | isVisible, 144 | mountedItem, 145 | } = usePresenceSwitch(children, { transitionDuration }); 146 | 147 | if (!isMounted) { 148 | return null; 149 | } 150 | 151 | return ( 152 |
162 | {mountedItem} 163 |
164 | ); 165 | } 166 | ``` 167 | 168 | ## Related 169 | 170 | - [`AnimatePresence` of `framer-motion`](https://www.framer.com/docs/animate-presence/) 171 | - [`Transition` of `react-transition-group`](http://reactcommunity.org/react-transition-group/transition) 172 | -------------------------------------------------------------------------------- /packages/use-presence/media/use-presence-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amannn/react-hooks/3a372ae859b515165934bef2c6140b44671125ae/packages/use-presence/media/use-presence-demo.gif -------------------------------------------------------------------------------- /packages/use-presence/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-presence", 3 | "version": "1.3.0", 4 | "license": "MIT", 5 | "description": "A lightweight React hook to animate the presence of an element.", 6 | "author": "Jan Amann ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-presence" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", 13 | "build": "tsdx build --tsconfig tsconfig.json", 14 | "test": "tsdx test", 15 | "lint": "eslint src test && tsc --noEmit", 16 | "prepublish": "npm run build" 17 | }, 18 | "main": "dist/index.js", 19 | "module": "dist/use-presence.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "README.md", 23 | "dist" 24 | ], 25 | "peerDependencies": { 26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 28 | }, 29 | "dependencies": { 30 | "tslib": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^10.0.0", 34 | "@testing-library/react": "^16.1.0", 35 | "@types/react": "^19.0.0", 36 | "@types/react-dom": "^19.0.0", 37 | "eslint": "8.13.0", 38 | "eslint-config-molindo": "^6.0.0", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "tsdx": "^0.14.1", 42 | "typescript": "^4.8.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/use-presence/src/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './usePresence'; 2 | export {default as usePresenceSwitch} from './usePresenceSwitch'; 3 | -------------------------------------------------------------------------------- /packages/use-presence/src/usePresence.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | 3 | type SharedTransitionConfig = { 4 | /** Duration in milliseconds used both for enter and exit transitions. */ 5 | transitionDuration: number; 6 | }; 7 | 8 | type SeparateTransitionConfig = { 9 | /** Duration in milliseconds used for enter transitions (overrides `transitionDuration` if provided). */ 10 | enterTransitionDuration: number; 11 | /** Duration in milliseconds used for exit transitions (overrides `transitionDuration` if provided). */ 12 | exitTransitionDuration: number; 13 | }; 14 | 15 | /** 16 | * Animates the appearance of its children. 17 | */ 18 | export default function usePresence( 19 | /** Indicates whether the component that the resulting values will be used upon should be visible to the user. */ 20 | isVisible: boolean, 21 | opts: ( 22 | | SharedTransitionConfig 23 | | SeparateTransitionConfig 24 | | (SharedTransitionConfig & SeparateTransitionConfig) 25 | ) & { 26 | /** Opt-in to animating the entering of an element if `isVisible` is `true` during the initial mount. */ 27 | initialEnter?: boolean; 28 | } 29 | ) { 30 | const exitTransitionDuration = 31 | 'exitTransitionDuration' in opts 32 | ? opts.exitTransitionDuration 33 | : opts.transitionDuration; 34 | const enterTransitionDuration = 35 | 'enterTransitionDuration' in opts 36 | ? opts.enterTransitionDuration 37 | : opts.transitionDuration; 38 | 39 | const initialEnter = opts.initialEnter ?? false; 40 | const [animateIsVisible, setAnimateIsVisible] = useState( 41 | initialEnter ? false : isVisible 42 | ); 43 | const [isMounted, setIsMounted] = useState(isVisible); 44 | const [hasEntered, setHasEntered] = useState( 45 | initialEnter ? false : isVisible 46 | ); 47 | 48 | const isExiting = isMounted && !isVisible; 49 | const isEntering = isVisible && !hasEntered; 50 | const isAnimating = isEntering || isExiting; 51 | 52 | useEffect(() => { 53 | if (isVisible) { 54 | // `animateVisible` needs to be set to `true` in a second step, as 55 | // when both flags would be flipped at the same time, there would 56 | // be no transition. See the second effect below. 57 | setIsMounted(true); 58 | } else { 59 | setHasEntered(false); 60 | setAnimateIsVisible(false); 61 | 62 | const timeoutId = setTimeout(() => { 63 | setIsMounted(false); 64 | }, exitTransitionDuration); 65 | 66 | return () => { 67 | clearTimeout(timeoutId); 68 | }; 69 | } 70 | }, [isVisible, exitTransitionDuration]); 71 | 72 | useEffect(() => { 73 | if (isVisible && isMounted && !animateIsVisible) { 74 | // Force a reflow so the initial styles are flushed to the DOM 75 | if (typeof document !== undefined) { 76 | // We need a side effect so Terser doesn't remove this statement 77 | (window as any)._usePresenceReflow = document.body.offsetHeight; 78 | } 79 | 80 | const animationFrameId = requestAnimationFrame(() => { 81 | setAnimateIsVisible(true); 82 | }); 83 | 84 | return () => { 85 | cancelAnimationFrame(animationFrameId); 86 | }; 87 | } 88 | }, [animateIsVisible, enterTransitionDuration, isMounted, isVisible]); 89 | 90 | useEffect(() => { 91 | if (animateIsVisible && !hasEntered) { 92 | const timeoutId = setTimeout(() => { 93 | setHasEntered(true); 94 | }, enterTransitionDuration); 95 | 96 | return () => { 97 | clearTimeout(timeoutId); 98 | }; 99 | } 100 | }, [animateIsVisible, enterTransitionDuration, hasEntered]); 101 | 102 | return { 103 | isMounted, 104 | isVisible: animateIsVisible, 105 | isAnimating, 106 | isEntering, 107 | isExiting 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /packages/use-presence/src/usePresenceSwitch.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from 'react'; 2 | import usePresence from './usePresence'; 3 | 4 | export default function usePresenceSwitch( 5 | item: ItemType | undefined, 6 | opts: Parameters[1] 7 | ) { 8 | const [mountedItem, setMountedItem] = useState(item); 9 | const [shouldBeMounted, setShouldBeMounted] = useState(item !== undefined); 10 | const {isMounted, ...rest} = usePresence(shouldBeMounted, opts); 11 | 12 | useEffect(() => { 13 | if (mountedItem !== item) { 14 | if (isMounted) { 15 | setShouldBeMounted(false); 16 | } else if (item !== undefined) { 17 | setMountedItem(item); 18 | setShouldBeMounted(true); 19 | } 20 | } else if (item === undefined) { 21 | setShouldBeMounted(false); 22 | } else if (item !== undefined) { 23 | setShouldBeMounted(true); 24 | } 25 | }, [item, mountedItem, shouldBeMounted, isMounted]); 26 | 27 | return { 28 | ...rest, 29 | isMounted: isMounted && mountedItem !== undefined, 30 | mountedItem 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/use-presence/test/usePresence.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, waitFor} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import usePresence from '../src'; 4 | 5 | function Expander({ 6 | children, 7 | initialEnter, 8 | isOpen, 9 | transitionDuration = 50 10 | }: { 11 | initialEnter?: boolean; 12 | children: React.ReactNode; 13 | isOpen: boolean; 14 | transitionDuration?: number; 15 | }) { 16 | const values = usePresence(isOpen, {transitionDuration, initialEnter}); 17 | const {isAnimating, isMounted, isVisible} = values; 18 | const testId = 19 | Object.entries(values) 20 | .filter(([, value]) => value) 21 | .map(([key]) => key) 22 | .join(', ') || 'none'; 23 | 24 | return ( 25 |
26 | {isMounted && ( 27 |
43 | {children} 44 |
45 | )} 46 |
47 | ); 48 | } 49 | 50 | function Component( 51 | props: Omit, 'children'> 52 | ) { 53 | return Hello; 54 | } 55 | 56 | it('immediately mounts an element by default without animation', () => { 57 | const {getByTestId} = render(); 58 | getByTestId('isMounted, isVisible'); 59 | }); 60 | 61 | it('can animate the appearance of an element', async () => { 62 | const {getByTestId} = render(); 63 | getByTestId('isMounted, isAnimating, isEntering'); 64 | await waitFor(() => 65 | getByTestId('isMounted, isVisible, isAnimating, isEntering') 66 | ); 67 | await waitFor(() => getByTestId('isMounted, isVisible')); 68 | }); 69 | 70 | it('can animate the exit of an element', async () => { 71 | const {getByTestId, rerender} = render(); 72 | getByTestId('isMounted, isVisible'); 73 | rerender(); 74 | getByTestId('isMounted, isAnimating, isExiting'); 75 | await waitFor(() => getByTestId('isMounted, isAnimating, isExiting')); 76 | await waitFor(() => getByTestId('none')); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/use-presence/test/usePresenceSwitch.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, waitFor} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import {usePresenceSwitch} from '../src'; 4 | 5 | function Expander({ 6 | initialEnter, 7 | text, 8 | transitionDuration = 50 9 | }: { 10 | initialEnter?: boolean; 11 | text?: string; 12 | transitionDuration?: number; 13 | }) { 14 | const {mountedItem, ...values} = usePresenceSwitch(text, { 15 | transitionDuration, 16 | initialEnter 17 | }); 18 | const {isMounted, isVisible} = values; 19 | const testId = 20 | Object.entries(values) 21 | .filter(([, value]) => value) 22 | .map(([key]) => key) 23 | .join(', ') || 'none'; 24 | 25 | return ( 26 |
27 | {isMounted ? ( 28 |
38 | {mountedItem} 39 |
40 | ) : ( 41 |
Nothing mounted
42 | )} 43 |
44 | ); 45 | } 46 | 47 | it("can animate the exit and re-entrance of a component that has changed it's rendered data", async () => { 48 | const {getByTestId, getByText, rerender} = render( 49 | 50 | ); 51 | getByTestId('isVisible, isMounted'); 52 | getByText('initial value'); 53 | rerender(); 54 | getByTestId('isAnimating, isExiting, isMounted'); 55 | getByText('initial value'); 56 | await waitFor(() => getByTestId('isVisible, isMounted')); 57 | getByText('re-assigned value'); 58 | }); 59 | 60 | it("can animate the initial entrance and exit of a component based on it's rendered data", async () => { 61 | const {getByTestId, getByText, rerender} = render( 62 | 63 | ); 64 | getByTestId('none'); 65 | getByText('Nothing mounted'); 66 | rerender(); 67 | getByTestId('isAnimating, isEntering, isMounted'); 68 | getByText('initial value'); 69 | await waitFor(() => getByTestId('isVisible, isMounted')); 70 | rerender(); 71 | getByTestId('isAnimating, isExiting, isMounted'); 72 | getByText('initial value'); 73 | await waitFor(() => getByTestId('none')); 74 | getByText('Nothing mounted'); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/use-presence/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src", "test", "types", "../../types"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/use-promised/.eslintrc.js: -------------------------------------------------------------------------------- 1 | require('eslint-config-molindo/setupPlugins'); 2 | 3 | module.exports = { 4 | extends: ['molindo/typescript', 'molindo/react'], 5 | env: { 6 | node: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/use-promised/README.md: -------------------------------------------------------------------------------- 1 | # use-promised 2 | 3 | [![Stable release](https://img.shields.io/npm/v/use-promised.svg)](https://npm.im/use-promised) 4 | 5 | A React hook to implement asynchronous callbacks without having to deal with asynchronicity. 6 | 7 | ## The problem 8 | 9 | Can you tell what could go wrong with this code? 10 | 11 | ```jsx 12 | function FeedbackForm() { 13 | const [result, setResult] = useState(); 14 | const [error, setError] = useState(); 15 | 16 | function onSubmit() { 17 | API.submitFeedback() 18 | .then((receivedResult) => { 19 | setResult(receivedResult); 20 | }) 21 | .catch((receivedError) => { 22 | setError(receivedError); 23 | }); 24 | } 25 | 26 | return ( 27 | <> 28 | 31 |

Result: {result}

32 |

Error: {error}

33 | ... 34 | 35 | ); 36 | } 37 | ``` 38 | 39 | Here are some issues with this code: 40 | 41 | 1. The button can be submitted multiple times while the operation is pending. The fix is adding more state to track the loading state. 42 | 2. When the button is clicked multiple times, there's a race condition which result will be shown eventually. 43 | 3. When the component unmounts in the middle of the request, you'll see the dreaded "Can't perform a React state update on an unmounted component" warning. 44 | 4. If an error is received, it won't be removed when a new attempt is made – even if a subsequent request succeeds. 45 | 46 | The list goes on but the point is: **Handling async callbacks in React components is hard**. 47 | 48 | Maybe you've heard that you can avoid these issues by moving your code into `useEffect`, but [that hook has its own peculiarities to be aware of](https://overreacted.io/a-complete-guide-to-useeffect/). 49 | 50 | ## This solution 51 | 52 | This is a custom hook that attempts to remove all the complexity that comes with handling asynchronicity in callbacks correctly. 53 | 54 | **Features:** 55 | 56 | - Feels like synchronous programming – no `useEffect`. 57 | - Pending requests are canceled when they are interrupted by another request. 58 | - Impossible states like having a result and error simultaneously are prevented. 59 | - When you're using TypeScript you'll benefit from additional guardrails. 60 | - If your asynchronous callback reaches through multiple levels of components, you can subscribe to the promise result right on the level where you need it – no need to pass down a loading and error state. If desired, you can subscribe in multiple components at the same time. 61 | 62 | ## Example 63 | 64 | ```jsx 65 | import usePromised from 'use-promised'; 66 | 67 | function FeedbackForm() { 68 | const [promise, setPromise] = usePromised(); 69 | 70 | function onSubmit() { 71 | setPromise(API.submitFeedback()); 72 | } 73 | 74 | return ( 75 | <> 76 | 83 | {promise.fulfilled && ( 84 |

Result: {promise.result}

85 | )} 86 | {promise.rejected && ( 87 |

Error: {promise.error}

88 | )} 89 | 90 | ); 91 | } 92 | ``` 93 | 94 | Note that you can also read the state in a generic fashion from `promise.state`. 95 | -------------------------------------------------------------------------------- /packages/use-promised/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-promised", 3 | "version": "1.4.0", 4 | "license": "MIT", 5 | "description": "A React hook to implement asynchronous callbacks without having to deal with asynchronicity.", 6 | "author": "Jan Amann ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/amannn/react-hooks/tree/master/packages/use-promised" 10 | }, 11 | "scripts": { 12 | "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", 13 | "build": "tsdx build --tsconfig tsconfig.json", 14 | "test": "tsdx test", 15 | "lint": "eslint src test && tsc --noEmit", 16 | "prepublish": "npm run build" 17 | }, 18 | "main": "dist/index.js", 19 | "module": "dist/use-promised.esm.js", 20 | "typings": "dist/index.d.ts", 21 | "files": [ 22 | "README.md", 23 | "dist" 24 | ], 25 | "peerDependencies": { 26 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 27 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 28 | }, 29 | "dependencies": { 30 | "tslib": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^10.0.0", 34 | "@testing-library/react": "^16.1.0", 35 | "@types/react": "^19.0.0", 36 | "@types/react-dom": "^19.0.0", 37 | "eslint": "8.13.0", 38 | "eslint-config-molindo": "^6.0.0", 39 | "react": "^19.0.0", 40 | "react-dom": "^19.0.0", 41 | "tsdx": "^0.14.1", 42 | "typescript": "^4.8.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/use-promised/src/index.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from 'react'; 2 | 3 | export enum PromiseState { 4 | IDLE = 'idle', 5 | PENDING = 'pending', 6 | REJECTED = 'rejected', 7 | FULFILLED = 'fulfilled' 8 | } 9 | 10 | export type PromiseData = 11 | | { 12 | state: PromiseState.IDLE; 13 | idle: true; 14 | pending: false; 15 | rejected: false; 16 | fulfilled: false; 17 | result: undefined; 18 | error: undefined; 19 | } 20 | | { 21 | state: PromiseState.PENDING; 22 | idle: false; 23 | pending: true; 24 | rejected: false; 25 | fulfilled: false; 26 | result: undefined; 27 | error: undefined; 28 | } 29 | | { 30 | state: PromiseState.REJECTED; 31 | idle: false; 32 | pending: false; 33 | rejected: true; 34 | fulfilled: false; 35 | result: undefined; 36 | error: ErrorType; 37 | } 38 | | { 39 | state: PromiseState.FULFILLED; 40 | idle: false; 41 | pending: false; 42 | rejected: false; 43 | fulfilled: true; 44 | result: ResultType; 45 | error: undefined; 46 | }; 47 | 48 | export default function usePromised(): [ 49 | PromiseData, 50 | (promise: Promise | undefined) => void 51 | ] { 52 | const [promise, setPromise] = useState>(); 53 | const [data, setData] = useState>({ 54 | state: PromiseState.IDLE, 55 | idle: true, 56 | pending: false, 57 | rejected: false, 58 | fulfilled: false, 59 | error: undefined, 60 | result: undefined 61 | }); 62 | 63 | useEffect(() => { 64 | let isCanceled = false; 65 | 66 | if (promise) { 67 | setData({ 68 | state: PromiseState.PENDING, 69 | idle: false, 70 | pending: true, 71 | rejected: false, 72 | fulfilled: false, 73 | error: undefined, 74 | result: undefined 75 | }); 76 | 77 | promise 78 | .then((receivedResult) => { 79 | if (isCanceled) return; 80 | 81 | setData({ 82 | state: PromiseState.FULFILLED, 83 | idle: false, 84 | pending: false, 85 | rejected: false, 86 | fulfilled: true, 87 | result: receivedResult, 88 | error: undefined 89 | }); 90 | }) 91 | .catch((receivedError) => { 92 | if (isCanceled) return; 93 | 94 | setData({ 95 | state: PromiseState.REJECTED, 96 | idle: false, 97 | pending: false, 98 | rejected: true, 99 | fulfilled: false, 100 | error: receivedError, 101 | result: undefined 102 | }); 103 | }); 104 | } else { 105 | setData({ 106 | state: PromiseState.IDLE, 107 | idle: true, 108 | pending: false, 109 | rejected: false, 110 | fulfilled: false, 111 | result: undefined, 112 | error: undefined 113 | }); 114 | } 115 | 116 | return () => { 117 | isCanceled = true; 118 | }; 119 | }, [promise]); 120 | 121 | return [data, setPromise]; 122 | } 123 | -------------------------------------------------------------------------------- /packages/use-promised/test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import {fireEvent, render, screen, waitFor} from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import usePromised, {PromiseState} from '../src'; 4 | 5 | const API = { 6 | submitFeedback() { 7 | return Promise.resolve('Thank you for your feedback!'); 8 | }, 9 | triggerError() { 10 | return Promise.reject(new Error('Please login first.')); 11 | } 12 | }; 13 | 14 | function FeedbackForm() { 15 | const [promise, setPromise] = usePromised(); 16 | 17 | function onSubmit() { 18 | setPromise(API.submitFeedback()); 19 | } 20 | 21 | function onTriggerError() { 22 | setPromise(API.triggerError()); 23 | } 24 | 25 | function onReset() { 26 | setPromise(undefined); 27 | } 28 | 29 | return ( 30 | <> 31 | 34 | 37 | 40 |

State: {promise.state}

41 | 42 | {/* Direct access without checking a state variable first */} 43 |

{promise.result}

44 | 45 | {/* Assessing the state before checking state-specific properties */} 46 | {promise.fulfilled &&

Result: {promise.result}

} 47 | {promise.rejected &&

Error: {promise.error.message}

} 48 | 49 | ); 50 | } 51 | 52 | it('handles an async flow', async () => { 53 | render(); 54 | screen.getByText(`State: ${PromiseState.IDLE}`); 55 | 56 | fireEvent.click(screen.getByText('Submit feedback')); 57 | screen.getByText(`State: ${PromiseState.PENDING}`); 58 | 59 | await waitFor(() => screen.getByText(`State: ${PromiseState.FULFILLED}`)); 60 | screen.getByText('Result: Thank you for your feedback!'); 61 | }); 62 | 63 | it('handles errors', async () => { 64 | render(); 65 | screen.getByText(`State: ${PromiseState.IDLE}`); 66 | 67 | fireEvent.click(screen.getByText('Trigger error')); 68 | screen.getByText(`State: ${PromiseState.PENDING}`); 69 | 70 | await waitFor(() => screen.getByText(`State: ${PromiseState.REJECTED}`)); 71 | screen.getByText('Error: Please login first.'); 72 | }); 73 | 74 | it('can reset the state', async () => { 75 | render(); 76 | screen.getByText(`State: ${PromiseState.IDLE}`); 77 | 78 | fireEvent.click(screen.getByText('Submit feedback')); 79 | screen.getByText(`State: ${PromiseState.PENDING}`); 80 | 81 | fireEvent.click(screen.getByText('Reset')); 82 | screen.getByText(`State: ${PromiseState.IDLE}`); 83 | 84 | // Make sure the resolved promise is ignored 85 | await Promise.resolve(); 86 | screen.getByText(`State: ${PromiseState.IDLE}`); 87 | 88 | fireEvent.click(screen.getByText('Submit feedback')); 89 | screen.getByText(`State: ${PromiseState.PENDING}`); 90 | 91 | await waitFor(() => screen.getByText(`State: ${PromiseState.FULFILLED}`)); 92 | fireEvent.click(screen.getByText('Reset')); 93 | screen.getByText(`State: ${PromiseState.IDLE}`); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/use-promised/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src", "test", "types", "../../types"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "lib": ["dom", "esnext"], 5 | "skipLibCheck": true, 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "strict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "moduleResolution": "node", 14 | "jsx": "react", 15 | "esModuleInterop": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["packages", "types", "scripts", "example"], 4 | "compilerOptions": { 5 | "allowJs": false, 6 | "baseUrl": ".", 7 | "typeRoots": ["./node_modules/@types", "./types"], 8 | "paths": { 9 | "$test/*": ["test/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Declare global variables for TypeScript and VSCode. 2 | // Do not rename this file or move these types into index.d.ts 3 | // @see https://code.visualstudio.com/docs/nodejs/working-with-javascript#_global-variables-and-type-checking 4 | declare const __DEV__: boolean; 5 | declare const __VERSION__: string; 6 | declare const $FixMe: any; 7 | --------------------------------------------------------------------------------