├── .github └── dependabot.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── LICENSE ├── README.md ├── babel.config.json ├── dist ├── cjs │ ├── hooks │ │ ├── useTransitionMap.cjs │ │ ├── useTransitionState.cjs │ │ └── utils.cjs │ └── index.cjs └── esm │ ├── hooks │ ├── useTransitionMap.mjs │ ├── useTransitionState.mjs │ └── utils.mjs │ └── index.mjs ├── eslint.config.mjs ├── example ├── .gitignore ├── README.md ├── gh-pages.sh ├── package-lock.json ├── package.json ├── public │ ├── GitHub-64.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── components │ ├── BasicExample.js │ ├── CodeSandbox.js │ ├── StyledExample.js │ ├── SwitchExample.js │ ├── SwitchTransition.js │ └── SwitchTransitionMap.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── reportWebVitals.js │ └── setupTests.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── __tests__ │ ├── testUtils.js │ ├── useTransitionMap.test.js │ ├── useTransitionState.test.js │ └── utils.test.js ├── hooks │ ├── useTransitionMap.js │ ├── useTransitionState.js │ └── utils.js └── index.js └── types ├── index.d.ts └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | open-pull-requests-limit: 10 11 | versioning-strategy: increase 12 | schedule: 13 | interval: 'daily' 14 | - package-ecosystem: 'npm' 15 | directory: '/example' 16 | open-pull-requests-limit: 0 17 | versioning-strategy: increase 18 | schedule: 19 | interval: 'daily' 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | *.lcov 16 | 17 | # node-waf configuration 18 | .lock-wscript 19 | 20 | # production 21 | build 22 | 23 | # Dependency directories 24 | node_modules 25 | 26 | # TypeScript cache 27 | *.tsbuildinfo 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional eslint cache 33 | .eslintcache 34 | 35 | # Output of 'npm pack' 36 | *.tgz 37 | 38 | # dotenv environment variables file 39 | .env 40 | .env.test 41 | .env.production 42 | 43 | # Stores VSCode versions used for testing VSCode extensions 44 | .vscode-test 45 | 46 | # misc 47 | .DS_Store 48 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | dist -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: none 2 | singleQuote: true 3 | printWidth: 100 4 | overrides: 5 | - files: '*.md' 6 | options: 7 | proseWrap: never 8 | printWidth: 80 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Zheng Song 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-Transition-State 2 | 3 | > Zero dependency React transition state machine 4 | 5 | **[Live Demo](https://szhsin.github.io/react-transition-state/)** 6 | 7 | [![NPM](https://img.shields.io/npm/v/react-transition-state.svg)](https://www.npmjs.com/package/react-transition-state) [![NPM](https://img.shields.io/npm/dm/react-transition-state)](https://www.npmjs.com/package/react-transition-state) [![NPM](https://img.shields.io/bundlephobia/minzip/react-transition-state)](https://bundlephobia.com/package/react-transition-state) [![Known Vulnerabilities](https://snyk.io/test/github/szhsin/react-transition-state/badge.svg)](https://snyk.io/test/github/szhsin/react-transition-state) 8 | 9 | ## Features 10 | 11 | Inspired by the [React Transition Group](https://github.com/reactjs/react-transition-group), this tiny library helps you easily perform animations/transitions of your React component in a [fully controlled](https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#common-bugs-when-using-derived-state) manner, using a Hook API. 12 | 13 | - 🍭 Working with both CSS animation and transition. 14 | - 🔄 Moving React components in and out of DOM seamlessly. 15 | - 🚫 Using no [derived state](https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html). 16 | - 🚀 Efficient: each state transition results in at most one extract render for consuming component. 17 | - 🤏 Tiny: [~1KB](https://bundlephobia.com/package/react-transition-state)(post-treeshaking) and no dependencies, ideal for both component libraries and applications. 18 | 19 | 🤔 Not convinced? [See a comparison with _React Transition Group_](#comparisons-with-react-transition-group) 20 | 21 |
22 | 23 | ## State diagram 24 | 25 | ![state-diagram](https://user-images.githubusercontent.com/41896553/142855447-cb8d8730-f8fb-4296-a3db-d1523b0fa2d9.png) The `initialEntered` and `mountOnEnter` props are omitted from the diagram to keep it less convoluted. [Please read more details at the API section](#usetransitionstate-hook). 26 | 27 |
28 | 29 | ## Install 30 | 31 | ```bash 32 | # with npm 33 | npm install react-transition-state 34 | 35 | # with Yarn 36 | yarn add react-transition-state 37 | ``` 38 | 39 |
40 | 41 | ## Usage 42 | 43 | ### CSS example 44 | 45 | ```jsx 46 | import { useTransitionState } from 'react-transition-state'; 47 | 48 | function Example() { 49 | const [state, toggle] = useTransitionState({ timeout: 750, preEnter: true }); 50 | return ( 51 |
52 | 53 |
React transition state
54 |
55 | ); 56 | } 57 | 58 | export default Example; 59 | ``` 60 | 61 | ```css 62 | .example { 63 | transition: all 0.75s; 64 | } 65 | 66 | .example.preEnter, 67 | .example.exiting { 68 | opacity: 0; 69 | transform: scale(0.5); 70 | } 71 | 72 | .example.exited { 73 | display: none; 74 | } 75 | ``` 76 | 77 | **[Edit on CodeSandbox](https://codesandbox.io/s/react-transition-state-100io)** 78 | 79 |
80 | 81 | ### styled-components example 82 | 83 | ```jsx 84 | import styled from 'styled-components'; 85 | import { useTransitionState } from 'react-transition-state'; 86 | 87 | const Box = styled.div` 88 | transition: all 500ms; 89 | 90 | ${({ $status }) => 91 | ($status === 'preEnter' || $status === 'exiting') && 92 | ` 93 | opacity: 0; 94 | transform: scale(0.9); 95 | `} 96 | `; 97 | 98 | function StyledExample() { 99 | const [{ status, isMounted }, toggle] = useTransitionState({ 100 | timeout: 500, 101 | mountOnEnter: true, 102 | unmountOnExit: true, 103 | preEnter: true 104 | }); 105 | 106 | return ( 107 |
108 | {!isMounted && } 109 | {isMounted && ( 110 | 111 |

This message is being transitioned in and out of the DOM.

112 | 113 |
114 | )} 115 |
116 | ); 117 | } 118 | 119 | export default StyledExample; 120 | ``` 121 | 122 | **[Edit on CodeSandbox](https://codesandbox.io/s/react-transition-styled-3id7q)** 123 | 124 |
125 | 126 | ### tailwindcss example 127 | 128 | **[Edit on CodeSandbox](https://codesandbox.io/s/react-transition-tailwindcss-21nys)** 129 | 130 |
131 | 132 | ### Switch transition 133 | 134 | You can create switch transition effects using one of the provided hooks, 135 | 136 | - `useTransitionState` if the number of elements participating in the switch transition is static. 137 | - `useTransitionMap` if the number of elements participating in the switch transition is dynamic and only known at runtime. 138 | 139 | **[Edit on CodeSandbox](https://codesandbox.io/p/sandbox/react-switch-transition-x87jt8)** 140 | 141 |
142 | 143 | ### Perform appearing transition when page loads or a component mounts 144 | 145 | You can toggle on transition with the `useEffect` hook. 146 | 147 | ```js 148 | useEffect(() => { 149 | toggle(true); 150 | }, [toggle]); 151 | ``` 152 | 153 | **[Edit on CodeSandbox](https://codesandbox.io/s/react-transition-appear-9kkss3)** 154 | 155 |
156 | 157 | ## Comparisons with _React Transition Group_ 158 | 159 | | | React Transition Group | This library | 160 | | --- | --- | --- | 161 | | Use derived state | _Yes_ – use an `in` prop to trigger changes in a derived transition state | _No_ – there is only a single state which is triggered by a toggle function | 162 | | Controlled | _No_ –
Transition state is managed internally.
Resort to callback events to read the internal state. | _Yes_ –
Transition state is _lifted_ up into the consuming component.
You have direct access to the transition state. | 163 | | DOM updates | _Imperative_ – [commit changes into DOM imperatively](https://github.com/reactjs/react-transition-group/blob/5aa3fd2d7e3354a7e42505d55af605ff44f74e2e/src/CSSTransition.js#L10) to update `classes` | _Declarative_ – you declare [what the `classes` look like](https://github.com/szhsin/react-transition-state/blob/2ab44c12ac5d5283ec3bb997bfc1d5ef6dffb0ce/example/src/components/BasicExample.js#L31) and DOM updates are taken care of by `ReactDOM` | 164 | | Render something in response to state updates | _Resort to side effects_ – rendering based on [state update events](https://codesandbox.io/s/react-transition-state-vs-group-p45iy?file=/src/App.js:1010-1191) | _Pure_ – rendering based on [transition state](https://codesandbox.io/s/react-transition-state-vs-group-p45iy?file=/src/App.js:2168-2342) | 165 | | Working with _styled-components_ | Your code looks like –
`&.box-exit-active { opacity: 0; }`
`&.box-enter-active { opacity: 1; }` | Your code looks like –
`opacity: ${({ state }) => (state === 'exiting' ? '0' : '1')};`
It's the way how you normally use the _styled-components_ | 166 | | Bundle size | [![NPM](https://img.shields.io/bundlephobia/minzip/react-transition-group)](https://bundlephobia.com/package/react-transition-group) | ✅ [![NPM](https://img.shields.io/bundlephobia/minzip/react-transition-state)](https://bundlephobia.com/package/react-transition-state) | 167 | | Dependency count | [![NPM](https://badgen.net/bundlephobia/dependency-count/react-transition-group)](https://www.npmjs.com/package/react-transition-group?activeTab=dependencies) | ✅ [![NPM](https://badgen.net/bundlephobia/dependency-count/react-transition-state)](https://www.npmjs.com/package/react-transition-state?activeTab=dependencies) | 168 | 169 | This [CodeSandbox example](https://codesandbox.io/s/react-transition-state-vs-group-p45iy) demonstrates how the same transition can be implemented in a simpler, more declarative, and controllable manner than _React Transition Group_. 170 | 171 |
172 | 173 | ## API 174 | 175 | ### `useTransitionState` Hook 176 | 177 | ```typescript 178 | function useTransitionState( 179 | options?: TransitionOptions 180 | ): [TransitionState, (toEnter?: boolean) => void, () => void]; 181 | ``` 182 | 183 | #### Options 184 | 185 | | Name | Type | Default | Description | 186 | | --- | --- | --- | --- | 187 | | `enter` | boolean | true | Enable or disable enter phase transitions | 188 | | `exit` | boolean | true | Enable or disable exit phase transitions | 189 | | `preEnter` | boolean | | Add a 'preEnter' state immediately before 'entering', which is necessary to change DOM elements from unmounted or `display: none` with CSS transition (not necessary for CSS animation). | 190 | | `preExit` | boolean | | Add a 'preExit' state immediately before 'exiting' | 191 | | `initialEntered` | boolean | | Beginning from 'entered' state | 192 | | `mountOnEnter` | boolean | | State will be 'unmounted' until hit enter phase for the first time. It allows you to create lazily mounted component. | 193 | | `unmountOnExit` | boolean | | State will become 'unmounted' after 'exiting' finishes. It allows you to transition component out of DOM. | 194 | | `timeout` | number \|
{ enter?: number; exit?: number; } | | Set timeout in **ms** for transitions; you can set a single value or different values for enter and exit transitions. | 195 | | `onStateChange` | (event: { current: TransitionState }) => void | | Event fired when state has changed.

Prefer to read state from the hook function return value directly unless you want to perform some side effects in response to state changes.

_Note: create an event handler with `useCallback` if you need to keep `toggle` or `endTransition` function's identity stable across re-renders._ | 196 | 197 | #### Return value 198 | 199 | The `useTransitionState` Hook returns a tuple of values in the following order: 200 | 201 | 1. state: 202 | 203 | ```js 204 | { 205 | status: 'preEnter' | 206 | 'entering' | 207 | 'entered' | 208 | 'preExit' | 209 | 'exiting' | 210 | 'exited' | 211 | 'unmounted'; 212 | isMounted: boolean; 213 | isEnter: boolean; 214 | isResolved: boolean; 215 | } 216 | ``` 217 | 218 | 2. toggle: `(toEnter?: boolean) => void` 219 | 220 | - If no parameter is supplied, this function will toggle state between enter and exit phases. 221 | - You can set a boolean parameter to explicitly switch into one of the two phases. 222 | 223 | 3. endTransition: `() => void` 224 | 225 | - Call this function to stop a transition which will turn the state into 'entered' or 'exited'. 226 | - You don't need to call this function explicitly if a timeout value is provided in the hook options. 227 | - You can call this function explicitly in the `onAnimationEnd` or `onTransitionEnd` event. 228 | 229 |
230 | 231 | ### `useTransitionMap` Hook 232 | 233 | It's similar to the `useTransitionState` Hook except that it manages multiple states in a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) structure instead of a single state. 234 | 235 | #### Options 236 | 237 | It accepts all options as `useTransitionState` and the following ones: 238 | 239 | | Name | Type | Default | Description | 240 | | --- | --- | --- | --- | 241 | | `allowMultiple` | boolean | | Allow multiple items to be in the enter phase at the same time. | 242 | 243 | #### Return value 244 | 245 | The Hook returns an object of shape: 246 | 247 | ```js 248 | interface TransitionMapResult { 249 | stateMap: ReadonlyMap; 250 | toggle: (key: K, toEnter?: boolean) => void; 251 | toggleAll: (toEnter?: boolean) => void; 252 | endTransition: (key: K) => void; 253 | setItem: (key: K, options?: TransitionItemOptions) => void; 254 | deleteItem: (key: K) => boolean; 255 | } 256 | ``` 257 | 258 | `setItem` and `deleteItem` are used to add and remove items from the state map. 259 | 260 | ## License 261 | 262 | [MIT](https://github.com/szhsin/react-transition-state/blob/master/LICENSE) Licensed. 263 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": "defaults", 3 | "assumptions": { 4 | "constantReexports": true, 5 | "ignoreFunctionLength": true, 6 | "ignoreToPrimitiveHint": true, 7 | "iterableIsArray": true, 8 | "noDocumentAll": true, 9 | "noNewArrows": true, 10 | "objectRestNoSymbols": true, 11 | "pureGetters": true, 12 | "setComputedProperties": true, 13 | "setSpreadProperties": true, 14 | "skipForOfIteratorClosing": true 15 | }, 16 | "plugins": ["pure-annotations"], 17 | "presets": [ 18 | [ 19 | "@babel/preset-env", 20 | { 21 | "bugfixes": true, 22 | "include": [ 23 | "@babel/plugin-transform-nullish-coalescing-operator", 24 | "@babel/plugin-transform-optional-catch-binding" 25 | ], 26 | "exclude": ["@babel/plugin-transform-typeof-symbol"] 27 | } 28 | ] 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useTransitionMap.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var utils = require('./utils.cjs'); 5 | 6 | const updateState = (key, status, setStateMap, latestStateMap, timeoutId, onChange) => { 7 | clearTimeout(timeoutId); 8 | const state = utils.getState(status); 9 | const stateMap = new Map(latestStateMap.current); 10 | stateMap.set(key, state); 11 | setStateMap(stateMap); 12 | latestStateMap.current = stateMap; 13 | onChange && onChange({ 14 | key, 15 | current: state 16 | }); 17 | }; 18 | const useTransitionMap = ({ 19 | allowMultiple, 20 | enter = true, 21 | exit = true, 22 | preEnter, 23 | preExit, 24 | timeout, 25 | initialEntered, 26 | mountOnEnter, 27 | unmountOnExit, 28 | onStateChange: onChange 29 | } = {}) => { 30 | const [stateMap, setStateMap] = react.useState(new Map()); 31 | const latestStateMap = react.useRef(stateMap); 32 | const configMap = react.useRef(new Map()); 33 | const [enterTimeout, exitTimeout] = utils.getTimeout(timeout); 34 | const setItem = react.useCallback((key, config) => { 35 | const { 36 | initialEntered: _initialEntered = initialEntered 37 | } = config || {}; 38 | const status = _initialEntered ? utils.ENTERED : utils.startOrEnd(mountOnEnter); 39 | updateState(key, status, setStateMap, latestStateMap); 40 | configMap.current.set(key, {}); 41 | }, [initialEntered, mountOnEnter]); 42 | const deleteItem = react.useCallback(key => { 43 | const newStateMap = new Map(latestStateMap.current); 44 | if (newStateMap.delete(key)) { 45 | setStateMap(newStateMap); 46 | latestStateMap.current = newStateMap; 47 | configMap.current.delete(key); 48 | return true; 49 | } 50 | return false; 51 | }, []); 52 | const endTransition = react.useCallback(key => { 53 | const stateObj = latestStateMap.current.get(key); 54 | if (!stateObj) { 55 | process.env.NODE_ENV !== 'production' && console.error(`[React-Transition-State] invalid key: ${key}`); 56 | return; 57 | } 58 | const { 59 | timeoutId 60 | } = configMap.current.get(key); 61 | const status = utils.getEndStatus(stateObj._s, unmountOnExit); 62 | status && updateState(key, status, setStateMap, latestStateMap, timeoutId, onChange); 63 | }, [onChange, unmountOnExit]); 64 | const toggle = react.useCallback((key, toEnter) => { 65 | const stateObj = latestStateMap.current.get(key); 66 | if (!stateObj) { 67 | process.env.NODE_ENV !== 'production' && console.error(`[React-Transition-State] invalid key: ${key}`); 68 | return; 69 | } 70 | const config = configMap.current.get(key); 71 | const transitState = status => { 72 | updateState(key, status, setStateMap, latestStateMap, config.timeoutId, onChange); 73 | switch (status) { 74 | case utils.ENTERING: 75 | if (enterTimeout >= 0) config.timeoutId = setTimeout(() => endTransition(key), enterTimeout); 76 | break; 77 | case utils.EXITING: 78 | if (exitTimeout >= 0) config.timeoutId = setTimeout(() => endTransition(key), exitTimeout); 79 | break; 80 | case utils.PRE_ENTER: 81 | case utils.PRE_EXIT: 82 | config.timeoutId = utils.nextTick(transitState, status); 83 | break; 84 | } 85 | }; 86 | const enterStage = stateObj.isEnter; 87 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 88 | if (toEnter) { 89 | if (!enterStage) { 90 | transitState(enter ? preEnter ? utils.PRE_ENTER : utils.ENTERING : utils.ENTERED); 91 | !allowMultiple && latestStateMap.current.forEach((_, _key) => _key !== key && toggle(_key, false)); 92 | } 93 | } else { 94 | if (enterStage) { 95 | transitState(exit ? preExit ? utils.PRE_EXIT : utils.EXITING : utils.startOrEnd(unmountOnExit)); 96 | } 97 | } 98 | }, [onChange, endTransition, allowMultiple, enter, exit, preEnter, preExit, enterTimeout, exitTimeout, unmountOnExit]); 99 | const toggleAll = react.useCallback(toEnter => { 100 | if (!allowMultiple && toEnter !== false) return; 101 | for (const key of latestStateMap.current.keys()) toggle(key, toEnter); 102 | }, [allowMultiple, toggle]); 103 | return { 104 | stateMap, 105 | toggle, 106 | toggleAll, 107 | endTransition, 108 | setItem, 109 | deleteItem 110 | }; 111 | }; 112 | 113 | exports.useTransitionMap = useTransitionMap; 114 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useTransitionState.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var utils = require('./utils.cjs'); 5 | 6 | const updateState = (status, setState, latestState, timeoutId, onChange) => { 7 | clearTimeout(timeoutId.current); 8 | const state = utils.getState(status); 9 | setState(state); 10 | latestState.current = state; 11 | onChange && onChange({ 12 | current: state 13 | }); 14 | }; 15 | const useTransitionState = ({ 16 | enter = true, 17 | exit = true, 18 | preEnter, 19 | preExit, 20 | timeout, 21 | initialEntered, 22 | mountOnEnter, 23 | unmountOnExit, 24 | onStateChange: onChange 25 | } = {}) => { 26 | const [state, setState] = react.useState(() => utils.getState(initialEntered ? utils.ENTERED : utils.startOrEnd(mountOnEnter))); 27 | const latestState = react.useRef(state); 28 | const timeoutId = react.useRef(); 29 | const [enterTimeout, exitTimeout] = utils.getTimeout(timeout); 30 | const endTransition = react.useCallback(() => { 31 | const status = utils.getEndStatus(latestState.current._s, unmountOnExit); 32 | status && updateState(status, setState, latestState, timeoutId, onChange); 33 | }, [onChange, unmountOnExit]); 34 | const toggle = react.useCallback(toEnter => { 35 | const transitState = status => { 36 | updateState(status, setState, latestState, timeoutId, onChange); 37 | switch (status) { 38 | case utils.ENTERING: 39 | if (enterTimeout >= 0) timeoutId.current = setTimeout(endTransition, enterTimeout); 40 | break; 41 | case utils.EXITING: 42 | if (exitTimeout >= 0) timeoutId.current = setTimeout(endTransition, exitTimeout); 43 | break; 44 | case utils.PRE_ENTER: 45 | case utils.PRE_EXIT: 46 | timeoutId.current = utils.nextTick(transitState, status); 47 | break; 48 | } 49 | }; 50 | const enterStage = latestState.current.isEnter; 51 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 52 | if (toEnter) { 53 | !enterStage && transitState(enter ? preEnter ? utils.PRE_ENTER : utils.ENTERING : utils.ENTERED); 54 | } else { 55 | enterStage && transitState(exit ? preExit ? utils.PRE_EXIT : utils.EXITING : utils.startOrEnd(unmountOnExit)); 56 | } 57 | }, [endTransition, onChange, enter, exit, preEnter, preExit, enterTimeout, exitTimeout, unmountOnExit]); 58 | return [state, toggle, endTransition]; 59 | }; 60 | 61 | exports.useTransitionState = useTransitionState; 62 | -------------------------------------------------------------------------------- /dist/cjs/hooks/utils.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const PRE_ENTER = 0; 4 | const ENTERING = 1; 5 | const ENTERED = 2; 6 | const PRE_EXIT = 3; 7 | const EXITING = 4; 8 | const EXITED = 5; 9 | const UNMOUNTED = 6; 10 | const STATUS = ['preEnter', 'entering', 'entered', 'preExit', 'exiting', 'exited', 'unmounted']; 11 | const getState = status => ({ 12 | _s: status, 13 | status: STATUS[status], 14 | isEnter: status < PRE_EXIT, 15 | isMounted: status !== UNMOUNTED, 16 | isResolved: status === ENTERED || status > EXITING 17 | }); 18 | const startOrEnd = unmounted => unmounted ? UNMOUNTED : EXITED; 19 | const getEndStatus = (status, unmountOnExit) => { 20 | switch (status) { 21 | case ENTERING: 22 | case PRE_ENTER: 23 | return ENTERED; 24 | case EXITING: 25 | case PRE_EXIT: 26 | return startOrEnd(unmountOnExit); 27 | } 28 | }; 29 | const getTimeout = timeout => typeof timeout === 'object' ? [timeout.enter, timeout.exit] : [timeout, timeout]; 30 | const nextTick = (transitState, status) => setTimeout(() => { 31 | // Reading document.body.offsetTop can force browser to repaint before transition to the next state 32 | isNaN(document.body.offsetTop) || transitState(status + 1); 33 | }, 0); 34 | 35 | exports.ENTERED = ENTERED; 36 | exports.ENTERING = ENTERING; 37 | exports.EXITED = EXITED; 38 | exports.EXITING = EXITING; 39 | exports.PRE_ENTER = PRE_ENTER; 40 | exports.PRE_EXIT = PRE_EXIT; 41 | exports.STATUS = STATUS; 42 | exports.UNMOUNTED = UNMOUNTED; 43 | exports.getEndStatus = getEndStatus; 44 | exports.getState = getState; 45 | exports.getTimeout = getTimeout; 46 | exports.nextTick = nextTick; 47 | exports.startOrEnd = startOrEnd; 48 | -------------------------------------------------------------------------------- /dist/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { value: true }); 4 | 5 | var useTransitionState = require('./hooks/useTransitionState.cjs'); 6 | var useTransitionMap = require('./hooks/useTransitionMap.cjs'); 7 | 8 | 9 | 10 | exports.default = useTransitionState.useTransitionState; 11 | exports.useTransition = useTransitionState.useTransitionState; 12 | exports.useTransitionState = useTransitionState.useTransitionState; 13 | exports.useTransitionMap = useTransitionMap.useTransitionMap; 14 | -------------------------------------------------------------------------------- /dist/esm/hooks/useTransitionMap.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback } from 'react'; 2 | import { getTimeout, getState, getEndStatus, PRE_EXIT, nextTick, PRE_ENTER, EXITING, ENTERING, ENTERED, startOrEnd } from './utils.mjs'; 3 | 4 | const updateState = (key, status, setStateMap, latestStateMap, timeoutId, onChange) => { 5 | clearTimeout(timeoutId); 6 | const state = getState(status); 7 | const stateMap = new Map(latestStateMap.current); 8 | stateMap.set(key, state); 9 | setStateMap(stateMap); 10 | latestStateMap.current = stateMap; 11 | onChange && onChange({ 12 | key, 13 | current: state 14 | }); 15 | }; 16 | const useTransitionMap = ({ 17 | allowMultiple, 18 | enter = true, 19 | exit = true, 20 | preEnter, 21 | preExit, 22 | timeout, 23 | initialEntered, 24 | mountOnEnter, 25 | unmountOnExit, 26 | onStateChange: onChange 27 | } = {}) => { 28 | const [stateMap, setStateMap] = useState(new Map()); 29 | const latestStateMap = useRef(stateMap); 30 | const configMap = useRef(new Map()); 31 | const [enterTimeout, exitTimeout] = getTimeout(timeout); 32 | const setItem = useCallback((key, config) => { 33 | const { 34 | initialEntered: _initialEntered = initialEntered 35 | } = config || {}; 36 | const status = _initialEntered ? ENTERED : startOrEnd(mountOnEnter); 37 | updateState(key, status, setStateMap, latestStateMap); 38 | configMap.current.set(key, {}); 39 | }, [initialEntered, mountOnEnter]); 40 | const deleteItem = useCallback(key => { 41 | const newStateMap = new Map(latestStateMap.current); 42 | if (newStateMap.delete(key)) { 43 | setStateMap(newStateMap); 44 | latestStateMap.current = newStateMap; 45 | configMap.current.delete(key); 46 | return true; 47 | } 48 | return false; 49 | }, []); 50 | const endTransition = useCallback(key => { 51 | const stateObj = latestStateMap.current.get(key); 52 | if (!stateObj) { 53 | process.env.NODE_ENV !== 'production' && console.error(`[React-Transition-State] invalid key: ${key}`); 54 | return; 55 | } 56 | const { 57 | timeoutId 58 | } = configMap.current.get(key); 59 | const status = getEndStatus(stateObj._s, unmountOnExit); 60 | status && updateState(key, status, setStateMap, latestStateMap, timeoutId, onChange); 61 | }, [onChange, unmountOnExit]); 62 | const toggle = useCallback((key, toEnter) => { 63 | const stateObj = latestStateMap.current.get(key); 64 | if (!stateObj) { 65 | process.env.NODE_ENV !== 'production' && console.error(`[React-Transition-State] invalid key: ${key}`); 66 | return; 67 | } 68 | const config = configMap.current.get(key); 69 | const transitState = status => { 70 | updateState(key, status, setStateMap, latestStateMap, config.timeoutId, onChange); 71 | switch (status) { 72 | case ENTERING: 73 | if (enterTimeout >= 0) config.timeoutId = setTimeout(() => endTransition(key), enterTimeout); 74 | break; 75 | case EXITING: 76 | if (exitTimeout >= 0) config.timeoutId = setTimeout(() => endTransition(key), exitTimeout); 77 | break; 78 | case PRE_ENTER: 79 | case PRE_EXIT: 80 | config.timeoutId = nextTick(transitState, status); 81 | break; 82 | } 83 | }; 84 | const enterStage = stateObj.isEnter; 85 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 86 | if (toEnter) { 87 | if (!enterStage) { 88 | transitState(enter ? preEnter ? PRE_ENTER : ENTERING : ENTERED); 89 | !allowMultiple && latestStateMap.current.forEach((_, _key) => _key !== key && toggle(_key, false)); 90 | } 91 | } else { 92 | if (enterStage) { 93 | transitState(exit ? preExit ? PRE_EXIT : EXITING : startOrEnd(unmountOnExit)); 94 | } 95 | } 96 | }, [onChange, endTransition, allowMultiple, enter, exit, preEnter, preExit, enterTimeout, exitTimeout, unmountOnExit]); 97 | const toggleAll = useCallback(toEnter => { 98 | if (!allowMultiple && toEnter !== false) return; 99 | for (const key of latestStateMap.current.keys()) toggle(key, toEnter); 100 | }, [allowMultiple, toggle]); 101 | return { 102 | stateMap, 103 | toggle, 104 | toggleAll, 105 | endTransition, 106 | setItem, 107 | deleteItem 108 | }; 109 | }; 110 | 111 | export { useTransitionMap }; 112 | -------------------------------------------------------------------------------- /dist/esm/hooks/useTransitionState.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useCallback } from 'react'; 2 | import { getState, ENTERED, startOrEnd, getTimeout, getEndStatus, PRE_EXIT, nextTick, PRE_ENTER, EXITING, ENTERING } from './utils.mjs'; 3 | 4 | const updateState = (status, setState, latestState, timeoutId, onChange) => { 5 | clearTimeout(timeoutId.current); 6 | const state = getState(status); 7 | setState(state); 8 | latestState.current = state; 9 | onChange && onChange({ 10 | current: state 11 | }); 12 | }; 13 | const useTransitionState = ({ 14 | enter = true, 15 | exit = true, 16 | preEnter, 17 | preExit, 18 | timeout, 19 | initialEntered, 20 | mountOnEnter, 21 | unmountOnExit, 22 | onStateChange: onChange 23 | } = {}) => { 24 | const [state, setState] = useState(() => getState(initialEntered ? ENTERED : startOrEnd(mountOnEnter))); 25 | const latestState = useRef(state); 26 | const timeoutId = useRef(); 27 | const [enterTimeout, exitTimeout] = getTimeout(timeout); 28 | const endTransition = useCallback(() => { 29 | const status = getEndStatus(latestState.current._s, unmountOnExit); 30 | status && updateState(status, setState, latestState, timeoutId, onChange); 31 | }, [onChange, unmountOnExit]); 32 | const toggle = useCallback(toEnter => { 33 | const transitState = status => { 34 | updateState(status, setState, latestState, timeoutId, onChange); 35 | switch (status) { 36 | case ENTERING: 37 | if (enterTimeout >= 0) timeoutId.current = setTimeout(endTransition, enterTimeout); 38 | break; 39 | case EXITING: 40 | if (exitTimeout >= 0) timeoutId.current = setTimeout(endTransition, exitTimeout); 41 | break; 42 | case PRE_ENTER: 43 | case PRE_EXIT: 44 | timeoutId.current = nextTick(transitState, status); 45 | break; 46 | } 47 | }; 48 | const enterStage = latestState.current.isEnter; 49 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 50 | if (toEnter) { 51 | !enterStage && transitState(enter ? preEnter ? PRE_ENTER : ENTERING : ENTERED); 52 | } else { 53 | enterStage && transitState(exit ? preExit ? PRE_EXIT : EXITING : startOrEnd(unmountOnExit)); 54 | } 55 | }, [endTransition, onChange, enter, exit, preEnter, preExit, enterTimeout, exitTimeout, unmountOnExit]); 56 | return [state, toggle, endTransition]; 57 | }; 58 | 59 | export { useTransitionState }; 60 | -------------------------------------------------------------------------------- /dist/esm/hooks/utils.mjs: -------------------------------------------------------------------------------- 1 | const PRE_ENTER = 0; 2 | const ENTERING = 1; 3 | const ENTERED = 2; 4 | const PRE_EXIT = 3; 5 | const EXITING = 4; 6 | const EXITED = 5; 7 | const UNMOUNTED = 6; 8 | const STATUS = ['preEnter', 'entering', 'entered', 'preExit', 'exiting', 'exited', 'unmounted']; 9 | const getState = status => ({ 10 | _s: status, 11 | status: STATUS[status], 12 | isEnter: status < PRE_EXIT, 13 | isMounted: status !== UNMOUNTED, 14 | isResolved: status === ENTERED || status > EXITING 15 | }); 16 | const startOrEnd = unmounted => unmounted ? UNMOUNTED : EXITED; 17 | const getEndStatus = (status, unmountOnExit) => { 18 | switch (status) { 19 | case ENTERING: 20 | case PRE_ENTER: 21 | return ENTERED; 22 | case EXITING: 23 | case PRE_EXIT: 24 | return startOrEnd(unmountOnExit); 25 | } 26 | }; 27 | const getTimeout = timeout => typeof timeout === 'object' ? [timeout.enter, timeout.exit] : [timeout, timeout]; 28 | const nextTick = (transitState, status) => setTimeout(() => { 29 | // Reading document.body.offsetTop can force browser to repaint before transition to the next state 30 | isNaN(document.body.offsetTop) || transitState(status + 1); 31 | }, 0); 32 | 33 | export { ENTERED, ENTERING, EXITED, EXITING, PRE_ENTER, PRE_EXIT, STATUS, UNMOUNTED, getEndStatus, getState, getTimeout, nextTick, startOrEnd }; 34 | -------------------------------------------------------------------------------- /dist/esm/index.mjs: -------------------------------------------------------------------------------- 1 | export { useTransitionState as default, useTransitionState as useTransition, useTransitionState } from './hooks/useTransitionState.mjs'; 2 | export { useTransitionMap } from './hooks/useTransitionMap.mjs'; 3 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import globals from 'globals'; 5 | import prettier from 'eslint-config-prettier'; 6 | import jest from 'eslint-plugin-jest'; 7 | import react from 'eslint-plugin-react'; 8 | import reactHooks from 'eslint-plugin-react-hooks'; 9 | import reactHooksAddons from 'eslint-plugin-react-hooks-addons'; 10 | 11 | export default [ 12 | eslint.configs.recommended, 13 | prettier, 14 | jest.configs['flat/recommended'], 15 | jest.configs['flat/style'], 16 | react.configs.flat.recommended, 17 | reactHooksAddons.configs.recommended, 18 | { 19 | ignores: ['**/dist/', '**/types/', '**/coverage/', '**/build/'] 20 | }, 21 | { 22 | languageOptions: { 23 | ecmaVersion: 'latest', 24 | sourceType: 'module', 25 | parserOptions: { 26 | ecmaFeatures: { 27 | jsx: true 28 | } 29 | }, 30 | globals: { 31 | ...globals.browser, 32 | ...globals.node, 33 | ...globals.jest 34 | } 35 | }, 36 | settings: { 37 | react: { 38 | version: 'detect' 39 | } 40 | }, 41 | plugins: { 42 | jest, 43 | react, 44 | 'react-hooks': reactHooks 45 | }, 46 | rules: { 47 | 'no-console': ['error', { allow: ['warn', 'error'] }], 48 | 'react/react-in-jsx-scope': 0, 49 | 'react/prop-types': 0, 50 | 'react-hooks/rules-of-hooks': 'error', 51 | 'react-hooks/exhaustive-deps': 'error', 52 | 'react-hooks-addons/no-unused-deps': 'error' 53 | } 54 | } 55 | ]; 56 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # React-Transition-State examples 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /example/gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | check_str=$(pwd | grep "/example") 6 | if [ -z "$check_str" ]; then 7 | echo "Not in /example" 8 | exit 1 9 | fi 10 | 11 | rm -Rf build/ 12 | npm run build 13 | 14 | tmpdir="$HOME/gh-pages" 15 | rm -Rf "$tmpdir" 16 | mkdir "$tmpdir" 17 | mv build "$tmpdir" 18 | cd .. 19 | 20 | git checkout gh-pages 21 | check_str=$(git branch | grep "*" | grep "gh-pages") 22 | if [ -z "$check_str" ]; then 23 | echo "Not on branch gh-pages" 24 | exit 1 25 | fi 26 | 27 | rm -Rf static 28 | cp -Rf "$tmpdir/build/" . 29 | git add . 30 | git commit -m "Updates" 31 | rm -Rf "$tmpdir" 32 | echo "Ready to push gh-pages" 33 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-transition-state-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "/react-transition-state", 6 | "dependencies": { 7 | "react": "file:../node_modules/react", 8 | "react-dom": "file:../node_modules/react-dom", 9 | "react-scripts": "5.0.1", 10 | "react-transition-state": "file:..", 11 | "styled-components": "^6.1.14", 12 | "web-vitals": "^2.1.4" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/public/GitHub-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-transition-state/6d3ee630e25d1a481aa47ca6ed603fe6aeae02d7/example/public/GitHub-64.png -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-transition-state/6d3ee630e25d1a481aa47ca6ed603fe6aeae02d7/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React transition state 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-transition-state/6d3ee630e25d1a481aa47ca6ed603fe6aeae02d7/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-transition-state/6d3ee630e25d1a481aa47ca6ed603fe6aeae02d7/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .github { 6 | display: block; 7 | position: fixed; 8 | width: 10rem; 9 | height: 10rem; 10 | top: -5rem; 11 | right: -5rem; 12 | background-color: white; 13 | transform: rotate(45deg); 14 | } 15 | 16 | @media (max-width: 480px) { 17 | .github { 18 | width: 8rem; 19 | height: 8rem; 20 | top: -4rem; 21 | right: -4rem; 22 | } 23 | } 24 | 25 | .github img { 26 | position: absolute; 27 | bottom: -2px; 28 | left: 50%; 29 | transform: translateX(-50%); 30 | height: 30%; 31 | } 32 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import { BasicExample } from './components/BasicExample'; 2 | import { StyledExample } from './components/StyledExample'; 3 | import { SwitchExample } from './components/SwitchExample'; 4 | import './App.css'; 5 | 6 | function App() { 7 | return ( 8 |
9 | 10 | GitHub 11 | 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /example/src/components/BasicExample.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useTransitionState } from 'react-transition-state'; 3 | import { CodeSandbox } from './CodeSandbox'; 4 | 5 | const BasicExample = () => { 6 | const [unmountOnExit, setUnmountOnExit] = useState(true); 7 | const [{ status, isMounted }, toggle] = useTransitionState({ 8 | timeout: 500, 9 | initialEntered: true, 10 | preEnter: true, 11 | unmountOnExit 12 | }); 13 | 14 | return ( 15 |
16 |

CSS example

17 |
18 |
status: {status}
19 | 27 | 30 | 31 | Tip: open the browser dev tools to verify that the following message is being moved in and 32 | out of DOM. 33 | 34 |
35 | {isMounted &&
React transition state
} 36 | 37 |
38 | ); 39 | }; 40 | 41 | export { BasicExample }; 42 | -------------------------------------------------------------------------------- /example/src/components/CodeSandbox.js: -------------------------------------------------------------------------------- 1 | const CodeSandbox = ({ href }) => ( 2 |
3 | Edit on CodeSandbox 4 |
5 | ); 6 | 7 | export { CodeSandbox }; 8 | -------------------------------------------------------------------------------- /example/src/components/StyledExample.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { useTransitionState } from 'react-transition-state'; 4 | import { CodeSandbox } from './CodeSandbox'; 5 | 6 | const Container = styled.div` 7 | margin: 1rem; 8 | margin-top: 150px; 9 | `; 10 | 11 | const Box = styled.div` 12 | border: 2px solid; 13 | padding: 1rem; 14 | transition: all 500ms; 15 | max-width: 600px; 16 | margin: 0 auto; 17 | border-radius: 0.5rem; 18 | 19 | ${({ $status }) => 20 | ($status === 'preEnter' || $status === 'exiting') && 21 | ` 22 | opacity: 0; 23 | transform: scale(0.9); 24 | `} 25 | `; 26 | 27 | const StyledExample = () => { 28 | const [{ status, isMounted }, toggle] = useTransitionState({ 29 | timeout: 500, 30 | mountOnEnter: true, 31 | unmountOnExit: true, 32 | preEnter: true 33 | }); 34 | 35 | return ( 36 | 37 |

styled-components example

38 | {!isMounted && ( 39 | 42 | )} 43 | {isMounted && ( 44 | 45 |

status: {status}

46 |

This message is being transitioned in and out of the DOM.

47 | 50 |
51 | )} 52 | 53 |
54 | ); 55 | }; 56 | 57 | export { StyledExample }; 58 | -------------------------------------------------------------------------------- /example/src/components/SwitchExample.js: -------------------------------------------------------------------------------- 1 | import { SwitchTransition } from './SwitchTransition'; 2 | import { SwitchTransitionMap } from './SwitchTransitionMap'; 3 | import { CodeSandbox } from './CodeSandbox'; 4 | 5 | const SwitchExample = () => ( 6 |
7 |

Switch Transition example

8 |

Two elements switching

9 | 10 |

Any number of elements switching

11 | 12 | 13 |
14 | ); 15 | 16 | export { SwitchExample }; 17 | -------------------------------------------------------------------------------- /example/src/components/SwitchTransition.js: -------------------------------------------------------------------------------- 1 | import { useTransitionState } from 'react-transition-state'; 2 | 3 | // Ideal for creating switch transition for a small number of elements 4 | // Use `useTransitionState` hook once for each element in the switch transition 5 | export const SwitchTransition = () => { 6 | const transitionProps = { 7 | timeout: 300, 8 | mountOnEnter: true, 9 | unmountOnExit: true, 10 | preEnter: true 11 | }; 12 | 13 | const [state1, toggle1] = useTransitionState({ 14 | ...transitionProps, 15 | initialEntered: true 16 | }); 17 | const [state2, toggle2] = useTransitionState(transitionProps); 18 | const toggle = () => { 19 | toggle1(); 20 | toggle2(); 21 | }; 22 | 23 | return ( 24 |
25 | 26 | Button 1 27 | 28 | 29 | Button 2 30 | 31 |
32 | ); 33 | }; 34 | 35 | const SwitchButton = ({ state: { status, isMounted }, onClick, children }) => { 36 | if (!isMounted) return null; 37 | 38 | return ( 39 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /example/src/components/SwitchTransitionMap.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useTransitionMap } from 'react-transition-state'; 3 | 4 | // Creating switch transitions for a large number of elements, 5 | // or the number of elements is only known at runtime. 6 | // `useTransitionState` doesn't suffice as React hook has the limitation that it cannot be called in a loop 7 | const NUMBER_OF_BUTTONS = 5; 8 | const buttonArray = new Array(NUMBER_OF_BUTTONS).fill(0).map((_, i) => `Button ${i + 1}`); 9 | 10 | export const SwitchTransitionMap = () => { 11 | const transition = useTransitionMap({ 12 | timeout: 300, 13 | mountOnEnter: true, 14 | unmountOnExit: true, 15 | preEnter: true 16 | }); 17 | 18 | return ( 19 |
20 | {buttonArray.map((button, index) => ( 21 | 28 | {button} 29 | 30 | ))} 31 |
32 | ); 33 | }; 34 | 35 | const SwitchButton = ({ 36 | itemKey, 37 | nextItemKey, 38 | initialEntered, 39 | children, 40 | stateMap, 41 | toggle, 42 | setItem, 43 | deleteItem 44 | }) => { 45 | useEffect(() => { 46 | setItem(itemKey, { initialEntered }); 47 | return () => void deleteItem(itemKey); 48 | }, [setItem, deleteItem, itemKey, initialEntered]); 49 | 50 | const { status, isMounted } = stateMap.get(itemKey) || {}; 51 | 52 | if (!isMounted) return null; 53 | 54 | return ( 55 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 4 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | background-color: #18191a; 8 | color: #cad1d8; 9 | padding-bottom: 300px; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 14 | } 15 | 16 | ::-webkit-scrollbar { 17 | width: 7px; 18 | } 19 | 20 | ::-webkit-scrollbar-thumb { 21 | background-color: #666; 22 | } 23 | 24 | ::-webkit-scrollbar-track { 25 | background-color: #333; 26 | } 27 | 28 | h1 { 29 | color: goldenrod; 30 | } 31 | 32 | a { 33 | text-decoration: none; 34 | color: #69a6f8; 35 | } 36 | 37 | a:hover { 38 | text-decoration: underline; 39 | color: #3688f6; 40 | } 41 | 42 | .code-sandbox { 43 | display: block; 44 | font-weight: 800; 45 | padding: 1rem; 46 | margin-top: 1rem; 47 | } 48 | 49 | .btn { 50 | padding: 0.375rem 0.75rem; 51 | min-width: 6rem; 52 | cursor: pointer; 53 | font-size: 1rem; 54 | font-weight: 500; 55 | line-height: 1.5; 56 | color: inherit; 57 | background-color: #22262c; 58 | border-radius: 0.25rem; 59 | border: 1px solid #333; 60 | transition: all 0.15s ease-in-out; 61 | } 62 | 63 | .btn:hover { 64 | background-color: #2b3036; 65 | border-color: #858585; 66 | } 67 | 68 | .btn:active { 69 | background-color: #1d1e1f; 70 | border-color: #707070; 71 | } 72 | 73 | .btn:focus { 74 | outline: none; 75 | border-color: #707070; 76 | box-shadow: 0 0 0 1px #707070; 77 | } 78 | 79 | .basic-console { 80 | display: flex; 81 | flex-direction: column; 82 | align-items: center; 83 | margin: 3rem 0 2rem; 84 | } 85 | 86 | .basic-console em { 87 | max-width: 26rem; 88 | margin: 0; 89 | } 90 | 91 | .basic-status { 92 | font-size: 1.25rem; 93 | font-weight: 600; 94 | } 95 | 96 | .basic-console > * { 97 | margin-bottom: 1.25rem; 98 | } 99 | 100 | .basic-transition { 101 | font-size: 3rem; 102 | font-weight: 800; 103 | transition: all 0.5s; 104 | color: #bbb; 105 | } 106 | 107 | .basic-transition.preEnter, 108 | .basic-transition.exiting { 109 | opacity: 0; 110 | transform: scale(0.5); 111 | } 112 | 113 | .basic-transition.exited { 114 | display: none; 115 | } 116 | 117 | .switch-example { 118 | margin-top: 150px; 119 | } 120 | 121 | .switch-container { 122 | display: flex; 123 | justify-content: center; 124 | min-height: 40px; 125 | } 126 | 127 | .switch { 128 | transition: 129 | opacity 0.3s, 130 | transform 0.3s; 131 | position: absolute; 132 | } 133 | 134 | .switch.preEnter { 135 | opacity: 0; 136 | transform: translateX(-200%); 137 | } 138 | 139 | .switch.exiting { 140 | opacity: 0; 141 | transform: translateX(200%); 142 | } 143 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | createRoot(document.getElementById('root')).render(); 8 | 9 | // If you want to start measuring performance in your app, pass a function 10 | // to log results (for example: reportWebVitals(console.log)) 11 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 12 | reportWebVitals(); 13 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | coverageDirectory: 'coverage', 5 | setupFilesAfterEnv: ['regenerator-runtime/runtime.js'], 6 | testEnvironment: 'jsdom', 7 | testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-transition-state", 3 | "version": "2.3.1", 4 | "description": "Zero dependency React transition state machine.", 5 | "author": "Zheng Song", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/szhsin/react-transition-state.git" 10 | }, 11 | "homepage": "https://szhsin.github.io/react-transition-state/", 12 | "main": "./dist/cjs/index.cjs", 13 | "module": "./dist/esm/index.mjs", 14 | "types": "./types/index.d.ts", 15 | "sideEffects": false, 16 | "files": [ 17 | "dist/", 18 | "types/*.d.ts" 19 | ], 20 | "keywords": [ 21 | "react", 22 | "transition", 23 | "animation", 24 | "component", 25 | "hook", 26 | "state machine" 27 | ], 28 | "scripts": { 29 | "start": "rollup -c -w", 30 | "clean": "rm -Rf dist", 31 | "bundle": "rollup -c", 32 | "test": "jest", 33 | "eg": "npm start --prefix example", 34 | "types": "cd types && tsc", 35 | "lint": "eslint .", 36 | "lint:fix": "eslint --fix .", 37 | "pret": "prettier -c .", 38 | "pret:fix": "prettier -w .", 39 | "build": "run-s pret clean lint types bundle" 40 | }, 41 | "exports": { 42 | ".": { 43 | "types": "./types/index.d.ts", 44 | "require": "./dist/cjs/index.cjs", 45 | "default": "./dist/esm/index.mjs" 46 | }, 47 | "./package.json": "./package.json" 48 | }, 49 | "peerDependencies": { 50 | "react": ">=16.8.0", 51 | "react-dom": ">=16.8.0" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.27.1", 55 | "@babel/preset-env": "^7.27.2", 56 | "@rollup/plugin-babel": "^6.0.4", 57 | "@testing-library/react": "^16.3.0", 58 | "@types/jest": "^29.5.14", 59 | "babel-plugin-pure-annotations": "^0.1.2", 60 | "eslint": "^9.27.0", 61 | "eslint-config-prettier": "^10.1.5", 62 | "eslint-plugin-jest": "^28.11.0", 63 | "eslint-plugin-react": "^7.37.5", 64 | "eslint-plugin-react-hooks": "^5.2.0", 65 | "eslint-plugin-react-hooks-addons": "^0.5.0", 66 | "globals": "^16.1.0", 67 | "jest": "^29.7.0", 68 | "jest-environment-jsdom": "^29.7.0", 69 | "npm-run-all": "^4.1.5", 70 | "prettier": "^3.5.3", 71 | "react": "^19", 72 | "react-dom": "^19", 73 | "rollup": "^4.41.1", 74 | "typescript": "^5.8.3" 75 | }, 76 | "overrides": { 77 | "whatwg-url@11.0.0": { 78 | "tr46": "^4" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { babel } from '@rollup/plugin-babel'; 4 | 5 | /** 6 | * @type {import('rollup').RollupOptions} 7 | */ 8 | export default { 9 | input: 'src/index.js', 10 | external: ['react'], 11 | plugins: [babel({ babelHelpers: 'bundled' })], 12 | treeshake: { 13 | moduleSideEffects: false, 14 | propertyReadSideEffects: false 15 | }, 16 | output: [ 17 | { 18 | dir: 'dist/cjs', 19 | format: 'cjs', 20 | interop: 'default', 21 | exports: 'named', 22 | entryFileNames: '[name].cjs', 23 | preserveModules: true 24 | }, 25 | { 26 | dir: 'dist/esm', 27 | format: 'es', 28 | entryFileNames: '[name].mjs', 29 | preserveModules: true 30 | } 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /src/__tests__/testUtils.js: -------------------------------------------------------------------------------- 1 | export const STATUS = Object.freeze({ 2 | preEnter: 'preEnter', 3 | entering: 'entering', 4 | entered: 'entered', 5 | preExit: 'preExit', 6 | exiting: 'exiting', 7 | exited: 'exited', 8 | unmounted: 'unmounted' 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/useTransitionMap.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act, waitFor } from '@testing-library/react'; 2 | import { STATUS } from './testUtils'; 3 | import { useTransitionMap } from '../'; 4 | 5 | class Result { 6 | constructor(result) { 7 | this.result = result; 8 | } 9 | 10 | get current() { 11 | return this.result.current; 12 | } 13 | 14 | get stateMap() { 15 | return this.result.current.stateMap; 16 | } 17 | 18 | getStatus(key = 1) { 19 | return this.stateMap.get(key).status; 20 | } 21 | 22 | toggle({ key = 1, toEnter } = {}) { 23 | act(() => { 24 | this.result.current.toggle(key, toEnter); 25 | }); 26 | } 27 | 28 | toggleAll(toEnter) { 29 | act(() => { 30 | this.result.current.toggleAll(toEnter); 31 | }); 32 | } 33 | 34 | endTransition(key = 1) { 35 | act(() => { 36 | this.result.current.endTransition(key); 37 | }); 38 | } 39 | 40 | setItem(...arg) { 41 | act(() => { 42 | this.result.current.setItem(...arg); 43 | }); 44 | } 45 | 46 | deleteItem(...arg) { 47 | act(() => { 48 | this.result.current.deleteItem(...arg); 49 | }); 50 | } 51 | } 52 | 53 | const renderTransitionHook = (options) => { 54 | const render = jest.fn(); 55 | const { result, ...rest } = renderHook((props) => { 56 | render(); 57 | return useTransitionMap(props); 58 | }, options); 59 | 60 | return { result: new Result(result), render, ...rest }; 61 | }; 62 | 63 | const getOnChangeParams = (status, key = 1) => ({ 64 | key, 65 | current: expect.objectContaining({ status }) 66 | }); 67 | 68 | const onChange = jest.fn(); 69 | 70 | test('should toggle state', () => { 71 | const { result, render } = renderTransitionHook({ initialProps: { onStateChange: onChange } }); 72 | expect(render).toHaveBeenCalledTimes(1); 73 | result.setItem(1); 74 | 75 | result.toggle(); 76 | expect(result.getStatus()).toBe(STATUS.entering); 77 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering)); 78 | 79 | result.endTransition(); 80 | expect(result.getStatus()).toBe(STATUS.entered); 81 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entered)); 82 | 83 | result.toggle(); 84 | expect(result.getStatus()).toBe(STATUS.exiting); 85 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exiting)); 86 | 87 | result.endTransition(); 88 | expect(result.getStatus()).toBe(STATUS.exited); 89 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exited)); 90 | 91 | expect(render).toHaveBeenCalledTimes(6); 92 | expect(onChange).toHaveBeenCalledTimes(4); 93 | }); 94 | 95 | test('should transition to specific state', () => { 96 | const { result, render } = renderTransitionHook({ initialProps: { onStateChange: onChange } }); 97 | result.setItem(1); 98 | 99 | result.toggle({ toEnter: true }); 100 | expect(result.getStatus()).toBe(STATUS.entering); 101 | result.toggle({ toEnter: true }); 102 | expect(result.getStatus()).toBe(STATUS.entering); 103 | expect(render).toHaveBeenCalledTimes(3); 104 | 105 | result.toggle({ toEnter: false }); 106 | expect(result.getStatus()).toBe(STATUS.exiting); 107 | result.toggle({ toEnter: false }); 108 | expect(result.getStatus()).toBe(STATUS.exiting); 109 | expect(render).toHaveBeenCalledTimes(4); 110 | 111 | result.toggle({ toEnter: true }); 112 | expect(result.getStatus()).toBe(STATUS.entering); 113 | expect(render).toHaveBeenCalledTimes(5); 114 | 115 | result.endTransition(); 116 | expect(result.getStatus()).toBe(STATUS.entered); 117 | // Call endTransition again intentionally 118 | result.endTransition(); 119 | expect(result.getStatus()).toBe(STATUS.entered); 120 | 121 | expect(render).toHaveBeenCalledTimes(6); 122 | expect(onChange).toHaveBeenCalledTimes(4); 123 | }); 124 | 125 | test('should update state after timeout', async () => { 126 | const { result, render } = renderTransitionHook({ 127 | initialProps: { timeout: 50, onStateChange: onChange } 128 | }); 129 | result.setItem(1); 130 | 131 | result.toggle(); 132 | expect(result.getStatus()).toBe(STATUS.entering); 133 | expect(render).toHaveBeenCalledTimes(3); 134 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.entered)); 135 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entered)); 136 | expect(render).toHaveBeenCalledTimes(4); 137 | 138 | result.toggle(); 139 | expect(result.getStatus()).toBe(STATUS.exiting); 140 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.exited)); 141 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exited)); 142 | expect(render).toHaveBeenCalledTimes(6); 143 | 144 | result.toggle(); 145 | result.toggle(); 146 | result.toggle(); 147 | expect(result.getStatus()).toBe(STATUS.entering); 148 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.entered)); 149 | expect(render).toHaveBeenCalledTimes(10); 150 | 151 | await expect(() => 152 | waitFor(() => expect(render).toHaveBeenCalledTimes(11), { timeout: 200 }) 153 | ).rejects.toThrow(); 154 | }); 155 | 156 | test('should set enter and exit timeout separately', async () => { 157 | const { result, render, rerender } = renderTransitionHook({ 158 | initialProps: { timeout: { enter: 50 } } 159 | }); 160 | result.setItem(1); 161 | 162 | result.toggle(); 163 | expect(result.getStatus()).toBe(STATUS.entering); 164 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.entered)); 165 | 166 | result.toggle(); 167 | expect(result.getStatus()).toBe(STATUS.exiting); 168 | await expect(() => 169 | waitFor(() => expect(result.getStatus()).toBe(STATUS.exited), { timeout: 200 }) 170 | ).rejects.toThrow(); 171 | result.endTransition(); 172 | expect(result.getStatus()).toBe(STATUS.exited); 173 | expect(render).toHaveBeenCalledTimes(6); 174 | 175 | rerender({ timeout: { exit: 50 } }); 176 | result.toggle(); 177 | expect(result.getStatus()).toBe(STATUS.entering); 178 | await expect(() => 179 | waitFor(() => expect(result.getStatus()).toBe(STATUS.entered), { timeout: 200 }) 180 | ).rejects.toThrow(); 181 | result.endTransition(); 182 | expect(result.getStatus()).toBe(STATUS.entered); 183 | 184 | result.toggle(); 185 | expect(result.getStatus()).toBe(STATUS.exiting); 186 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.exited)); 187 | expect(render).toHaveBeenCalledTimes(11); 188 | }); 189 | 190 | test('should disable enter or exit phase', () => { 191 | const { result, render, rerender } = renderTransitionHook({ 192 | initialProps: { enter: false } 193 | }); 194 | result.setItem(1); 195 | 196 | result.toggle(); 197 | expect(result.getStatus()).toBe(STATUS.entered); 198 | result.toggle(); 199 | expect(result.getStatus()).toBe(STATUS.exiting); 200 | result.endTransition(); 201 | expect(result.getStatus()).toBe(STATUS.exited); 202 | 203 | rerender({ exit: false }); 204 | result.toggle(); 205 | expect(result.getStatus()).toBe(STATUS.entering); 206 | result.endTransition(); 207 | expect(result.getStatus()).toBe(STATUS.entered); 208 | result.toggle(); 209 | expect(result.getStatus()).toBe(STATUS.exited); 210 | 211 | rerender({ enter: false, exit: false }); 212 | result.toggle(); 213 | expect(result.getStatus()).toBe(STATUS.entered); 214 | result.toggle(); 215 | expect(result.getStatus()).toBe(STATUS.exited); 216 | 217 | expect(render).toHaveBeenCalledTimes(12); 218 | }); 219 | 220 | test('should enable preEnter or preExit state', async () => { 221 | const { result, render } = renderTransitionHook({ 222 | initialProps: { preEnter: true, preExit: true, onStateChange: onChange } 223 | }); 224 | result.setItem(1); 225 | 226 | result.toggle(); 227 | expect(result.getStatus()).toBe(STATUS.preEnter); 228 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.preEnter)); 229 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.entering)); 230 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering)); 231 | result.endTransition(); 232 | expect(result.getStatus()).toBe(STATUS.entered); 233 | 234 | result.toggle(); 235 | expect(result.getStatus()).toBe(STATUS.preExit); 236 | await waitFor(() => expect(result.getStatus()).toBe(STATUS.exiting)); 237 | result.endTransition(); 238 | expect(result.getStatus()).toBe(STATUS.exited); 239 | 240 | expect(render).toHaveBeenCalledTimes(8); 241 | }); 242 | 243 | test('should skip entering or exiting state', () => { 244 | const { result, render } = renderTransitionHook({ 245 | initialProps: { preEnter: true, preExit: true } 246 | }); 247 | result.setItem(1); 248 | 249 | result.toggle(); 250 | expect(result.getStatus()).toBe(STATUS.preEnter); 251 | result.endTransition(); 252 | expect(result.getStatus()).toBe(STATUS.entered); 253 | 254 | result.toggle(); 255 | expect(result.getStatus()).toBe(STATUS.preExit); 256 | result.endTransition(); 257 | expect(result.getStatus()).toBe(STATUS.exited); 258 | 259 | expect(render).toHaveBeenCalledTimes(6); 260 | }); 261 | 262 | test('should start from entered when initialEntered is set', () => { 263 | const { result, render } = renderTransitionHook({ 264 | initialProps: { initialEntered: true } 265 | }); 266 | result.setItem(1); 267 | result.setItem(2, { initialEntered: false }); 268 | result.setItem(3); 269 | expect(result.getStatus(1)).toBe(STATUS.entered); 270 | expect(result.getStatus(2)).toBe(STATUS.exited); 271 | expect(result.getStatus(3)).toBe(STATUS.entered); 272 | expect(render).toHaveBeenCalledTimes(4); 273 | 274 | result.toggle({ key: 2 }); 275 | expect(result.getStatus(1)).toBe(STATUS.exiting); 276 | expect(result.getStatus(2)).toBe(STATUS.entering); 277 | expect(result.getStatus(3)).toBe(STATUS.exiting); 278 | expect(render).toHaveBeenCalledTimes(5); 279 | }); 280 | 281 | test('should unmount when unmountOnExit is set', () => { 282 | const { result, render } = renderTransitionHook({ 283 | initialProps: { unmountOnExit: true, onStateChange: onChange } 284 | }); 285 | result.setItem(1); 286 | 287 | result.toggle(); 288 | expect(result.getStatus()).toBe(STATUS.entering); 289 | result.endTransition(); 290 | expect(result.getStatus()).toBe(STATUS.entered); 291 | 292 | result.toggle(); 293 | expect(result.getStatus()).toBe(STATUS.exiting); 294 | result.endTransition(); 295 | expect(result.getStatus()).toBe(STATUS.unmounted); 296 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.unmounted)); 297 | expect(render).toHaveBeenCalledTimes(6); 298 | }); 299 | 300 | test('returned functions have stable identity across re-renders', () => { 301 | const initialProps = { onStateChange: () => {}, mountOnEnter: true }; 302 | const { result, rerender } = renderTransitionHook({ 303 | initialProps 304 | }); 305 | const prev = { ...result.current }; 306 | result.setItem(1); 307 | result.setItem(2); 308 | 309 | expect(result.getStatus()).toBe(STATUS.unmounted); 310 | result.toggle(); 311 | expect(result.getStatus()).toBe(STATUS.entering); 312 | result.endTransition(); 313 | expect(result.getStatus()).toBe(STATUS.entered); 314 | rerender({ ...initialProps }); 315 | 316 | expect(result.current.toggle).toBe(prev.toggle); 317 | expect(result.current.endTransition).toBe(prev.endTransition); 318 | expect(result.current.setItem).toBe(prev.setItem); 319 | expect(result.current.deleteItem).toBe(prev.deleteItem); 320 | 321 | rerender({ ...initialProps, onStateChange: () => {} }); 322 | expect(result.current.toggle).not.toBe(prev.toggle); 323 | expect(result.current.endTransition).not.toBe(prev.endTransition); 324 | expect(result.current.setItem).toBe(prev.setItem); 325 | expect(result.current.deleteItem).toBe(prev.deleteItem); 326 | }); 327 | 328 | test('should set and delete items', () => { 329 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(); 330 | const { result } = renderTransitionHook(); 331 | result.setItem(1); 332 | result.setItem(2, { initialEntered: true }); 333 | result.setItem(3); 334 | result.toggle({ key: 1 }); 335 | result.endTransition(1); 336 | 337 | expect(result.getStatus(1)).toBe(STATUS.entered); 338 | expect(result.getStatus(2)).toBe(STATUS.exiting); 339 | expect(result.getStatus(3)).toBe(STATUS.exited); 340 | 341 | result.deleteItem(1); 342 | expect(() => result.getStatus(1)).toThrow(); 343 | // Call deleteItem again intentionally 344 | result.deleteItem(1); 345 | expect(errorSpy).not.toHaveBeenCalled(); 346 | result.toggle({ key: 1 }); 347 | expect(errorSpy).toHaveBeenCalledTimes(1); 348 | result.endTransition(1); 349 | expect(errorSpy).toHaveBeenCalledTimes(2); 350 | errorSpy.mockRestore(); 351 | }); 352 | 353 | test('should allow mutiple items to enter', () => { 354 | const { result } = renderTransitionHook({ 355 | initialProps: { allowMultiple: true, onStateChange: onChange } 356 | }); 357 | result.setItem(1); 358 | result.setItem(2); 359 | result.setItem(3); 360 | result.toggle({ key: 1 }); 361 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering, 1)); 362 | result.toggle({ key: 3 }); 363 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering, 3)); 364 | 365 | expect(result.getStatus(1)).toBe(STATUS.entering); 366 | expect(result.getStatus(2)).toBe(STATUS.exited); 367 | expect(result.getStatus(3)).toBe(STATUS.entering); 368 | }); 369 | 370 | test('should toggle all items when allowing multiple', () => { 371 | const { result, render } = renderTransitionHook({ 372 | initialProps: { enter: false, exit: false, allowMultiple: true, onStateChange: onChange } 373 | }); 374 | result.setItem(1, { initialEntered: true }); 375 | result.setItem(2); 376 | result.setItem(3, { initialEntered: true }); 377 | expect(result.getStatus(1)).toBe(STATUS.entered); 378 | expect(result.getStatus(2)).toBe(STATUS.exited); 379 | expect(result.getStatus(3)).toBe(STATUS.entered); 380 | 381 | result.toggleAll(); 382 | expect(result.getStatus(1)).toBe(STATUS.exited); 383 | expect(result.getStatus(2)).toBe(STATUS.entered); 384 | expect(result.getStatus(3)).toBe(STATUS.exited); 385 | 386 | result.toggleAll(true); 387 | expect(result.getStatus(1)).toBe(STATUS.entered); 388 | expect(result.getStatus(2)).toBe(STATUS.entered); 389 | expect(result.getStatus(3)).toBe(STATUS.entered); 390 | 391 | result.toggleAll(false); 392 | expect(result.getStatus(1)).toBe(STATUS.exited); 393 | expect(result.getStatus(2)).toBe(STATUS.exited); 394 | expect(result.getStatus(3)).toBe(STATUS.exited); 395 | 396 | expect(onChange).toHaveBeenNthCalledWith(1, getOnChangeParams(STATUS.exited, 1)); 397 | expect(onChange).toHaveBeenNthCalledWith(2, getOnChangeParams(STATUS.entered, 2)); 398 | expect(onChange).toHaveBeenNthCalledWith(3, getOnChangeParams(STATUS.exited, 3)); 399 | expect(onChange).toHaveBeenCalledTimes(8); 400 | expect(render).toHaveBeenCalledTimes(7); 401 | }); 402 | 403 | test('should only close all items when not allowing multiple', () => { 404 | const { result, render } = renderTransitionHook({ 405 | initialProps: { enter: false, exit: false, onStateChange: onChange } 406 | }); 407 | result.setItem(1, { initialEntered: true }); 408 | result.setItem(2); 409 | result.setItem(3, { initialEntered: true }); 410 | expect(result.getStatus(1)).toBe(STATUS.entered); 411 | expect(result.getStatus(2)).toBe(STATUS.exited); 412 | expect(result.getStatus(3)).toBe(STATUS.entered); 413 | 414 | result.toggleAll(); 415 | expect(result.getStatus(1)).toBe(STATUS.entered); 416 | expect(result.getStatus(2)).toBe(STATUS.exited); 417 | expect(result.getStatus(3)).toBe(STATUS.entered); 418 | 419 | result.toggleAll(true); 420 | expect(result.getStatus(1)).toBe(STATUS.entered); 421 | expect(result.getStatus(2)).toBe(STATUS.exited); 422 | expect(result.getStatus(3)).toBe(STATUS.entered); 423 | 424 | result.toggleAll(false); 425 | expect(result.getStatus(1)).toBe(STATUS.exited); 426 | expect(result.getStatus(2)).toBe(STATUS.exited); 427 | expect(result.getStatus(3)).toBe(STATUS.exited); 428 | 429 | expect(onChange).toHaveBeenNthCalledWith(1, getOnChangeParams(STATUS.exited, 1)); 430 | expect(onChange).toHaveBeenNthCalledWith(2, getOnChangeParams(STATUS.exited, 3)); 431 | expect(onChange).toHaveBeenCalledTimes(2); 432 | expect(render).toHaveBeenCalledTimes(5); 433 | }); 434 | -------------------------------------------------------------------------------- /src/__tests__/useTransitionState.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act, waitFor } from '@testing-library/react'; 2 | import { STATUS } from './testUtils'; 3 | import { useTransitionState } from '../'; 4 | 5 | const getOnChangeParams = (status) => ({ current: expect.objectContaining({ status }) }); 6 | 7 | class Result { 8 | constructor(result) { 9 | this.result = result; 10 | } 11 | 12 | get length() { 13 | return this.result.current.length; 14 | } 15 | 16 | get state() { 17 | return this.result.current[0].status; 18 | } 19 | 20 | get toggleFn() { 21 | return this.result.current[1]; 22 | } 23 | 24 | get endTransitionFn() { 25 | return this.result.current[2]; 26 | } 27 | 28 | toggle(toEnter) { 29 | act(() => { 30 | this.toggleFn(toEnter); 31 | }); 32 | } 33 | 34 | endTransition() { 35 | act(() => { 36 | this.endTransitionFn(); 37 | }); 38 | } 39 | } 40 | 41 | const renderTransitionHook = (options) => { 42 | const render = jest.fn(); 43 | const { result, ...rest } = renderHook((props) => { 44 | render(); 45 | return useTransitionState(props); 46 | }, options); 47 | 48 | return { result: new Result(result), render, ...rest }; 49 | }; 50 | 51 | const onChange = jest.fn(); 52 | 53 | test('should return correct value', () => { 54 | const { result } = renderTransitionHook(); 55 | 56 | expect(result).toHaveLength(3); 57 | expect(result.state).toBe(STATUS.exited); 58 | expect(typeof result.toggleFn).toBe('function'); 59 | expect(typeof result.endTransitionFn).toBe('function'); 60 | }); 61 | 62 | test('should toggle state', () => { 63 | const { result, render } = renderTransitionHook({ initialProps: { onStateChange: onChange } }); 64 | expect(render).toHaveBeenCalledTimes(1); 65 | 66 | result.toggle(); 67 | expect(result.state).toBe(STATUS.entering); 68 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering)); 69 | 70 | result.endTransition(); 71 | expect(result.state).toBe(STATUS.entered); 72 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entered)); 73 | 74 | result.toggle(); 75 | expect(result.state).toBe(STATUS.exiting); 76 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exiting)); 77 | 78 | result.endTransition(); 79 | expect(result.state).toBe(STATUS.exited); 80 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exited)); 81 | 82 | expect(render).toHaveBeenCalledTimes(5); 83 | expect(onChange).toHaveBeenCalledTimes(4); 84 | }); 85 | 86 | test('should transition to specific state', () => { 87 | const { result, render } = renderTransitionHook({ initialProps: { onStateChange: onChange } }); 88 | expect(render).toHaveBeenCalledTimes(1); 89 | 90 | result.toggle(true); 91 | expect(result.state).toBe(STATUS.entering); 92 | result.toggle(true); 93 | expect(result.state).toBe(STATUS.entering); 94 | expect(render).toHaveBeenCalledTimes(2); 95 | 96 | result.toggle(false); 97 | expect(result.state).toBe(STATUS.exiting); 98 | result.toggle(false); 99 | expect(result.state).toBe(STATUS.exiting); 100 | expect(render).toHaveBeenCalledTimes(3); 101 | 102 | result.toggle(true); 103 | expect(result.state).toBe(STATUS.entering); 104 | expect(render).toHaveBeenCalledTimes(4); 105 | 106 | result.endTransition(); 107 | expect(result.state).toBe(STATUS.entered); 108 | // Call endTransition again intentionally 109 | result.endTransition(); 110 | expect(result.state).toBe(STATUS.entered); 111 | 112 | expect(render).toHaveBeenCalledTimes(5); 113 | expect(onChange).toHaveBeenCalledTimes(4); 114 | }); 115 | 116 | test('should update state after timeout', async () => { 117 | const { result, render } = renderTransitionHook({ 118 | initialProps: { timeout: 50, onStateChange: onChange } 119 | }); 120 | 121 | result.toggle(); 122 | expect(result.state).toBe(STATUS.entering); 123 | expect(render).toHaveBeenCalledTimes(2); 124 | await waitFor(() => expect(result.state).toBe(STATUS.entered)); 125 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entered)); 126 | expect(render).toHaveBeenCalledTimes(3); 127 | 128 | result.toggle(); 129 | expect(result.state).toBe(STATUS.exiting); 130 | await waitFor(() => expect(result.state).toBe(STATUS.exited)); 131 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.exited)); 132 | expect(render).toHaveBeenCalledTimes(5); 133 | 134 | result.toggle(); 135 | result.toggle(); 136 | result.toggle(); 137 | expect(result.state).toBe(STATUS.entering); 138 | await waitFor(() => expect(result.state).toBe(STATUS.entered)); 139 | expect(render).toHaveBeenCalledTimes(9); 140 | 141 | await expect(() => 142 | waitFor(() => expect(render).toHaveBeenCalledTimes(10), { timeout: 200 }) 143 | ).rejects.toThrow(); 144 | }); 145 | 146 | test('should set enter and exit timeout separately', async () => { 147 | const { result, render, rerender } = renderTransitionHook({ 148 | initialProps: { timeout: { enter: 50 } } 149 | }); 150 | 151 | result.toggle(); 152 | expect(result.state).toBe(STATUS.entering); 153 | await waitFor(() => expect(result.state).toBe(STATUS.entered)); 154 | 155 | result.toggle(); 156 | expect(result.state).toBe(STATUS.exiting); 157 | await expect(() => 158 | waitFor(() => expect(result.state).toBe(STATUS.exited), { timeout: 200 }) 159 | ).rejects.toThrow(); 160 | result.endTransition(); 161 | expect(result.state).toBe(STATUS.exited); 162 | expect(render).toHaveBeenCalledTimes(5); 163 | 164 | rerender({ timeout: { exit: 50 } }); 165 | result.toggle(); 166 | expect(result.state).toBe(STATUS.entering); 167 | await expect(() => 168 | waitFor(() => expect(result.state).toBe(STATUS.entered), { timeout: 200 }) 169 | ).rejects.toThrow(); 170 | result.endTransition(); 171 | expect(result.state).toBe(STATUS.entered); 172 | 173 | result.toggle(); 174 | expect(result.state).toBe(STATUS.exiting); 175 | await waitFor(() => expect(result.state).toBe(STATUS.exited)); 176 | expect(render).toHaveBeenCalledTimes(10); 177 | }); 178 | 179 | test('should disable enter or exit phase', () => { 180 | const { result, render, rerender } = renderTransitionHook({ 181 | initialProps: { enter: false } 182 | }); 183 | 184 | result.toggle(); 185 | expect(result.state).toBe(STATUS.entered); 186 | result.toggle(); 187 | expect(result.state).toBe(STATUS.exiting); 188 | result.endTransition(); 189 | expect(result.state).toBe(STATUS.exited); 190 | 191 | rerender({ exit: false }); 192 | result.toggle(); 193 | expect(result.state).toBe(STATUS.entering); 194 | result.endTransition(); 195 | expect(result.state).toBe(STATUS.entered); 196 | result.toggle(); 197 | expect(result.state).toBe(STATUS.exited); 198 | 199 | rerender({ enter: false, exit: false }); 200 | result.toggle(); 201 | expect(result.state).toBe(STATUS.entered); 202 | result.toggle(); 203 | expect(result.state).toBe(STATUS.exited); 204 | 205 | expect(render).toHaveBeenCalledTimes(11); 206 | }); 207 | 208 | test('should enable preEnter or preExit state', async () => { 209 | const { result, render } = renderTransitionHook({ 210 | initialProps: { preEnter: true, preExit: true, onStateChange: onChange } 211 | }); 212 | 213 | result.toggle(); 214 | expect(result.state).toBe(STATUS.preEnter); 215 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.preEnter)); 216 | await waitFor(() => expect(result.state).toBe(STATUS.entering)); 217 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.entering)); 218 | result.endTransition(); 219 | expect(result.state).toBe(STATUS.entered); 220 | 221 | result.toggle(); 222 | expect(result.state).toBe(STATUS.preExit); 223 | await waitFor(() => expect(result.state).toBe(STATUS.exiting)); 224 | result.endTransition(); 225 | expect(result.state).toBe(STATUS.exited); 226 | 227 | expect(render).toHaveBeenCalledTimes(7); 228 | }); 229 | 230 | test('should skip entering or exiting state', () => { 231 | const { result, render } = renderTransitionHook({ 232 | initialProps: { preEnter: true, preExit: true } 233 | }); 234 | 235 | result.toggle(); 236 | expect(result.state).toBe(STATUS.preEnter); 237 | result.endTransition(); 238 | expect(result.state).toBe(STATUS.entered); 239 | 240 | result.toggle(); 241 | expect(result.state).toBe(STATUS.preExit); 242 | result.endTransition(); 243 | expect(result.state).toBe(STATUS.exited); 244 | 245 | expect(render).toHaveBeenCalledTimes(5); 246 | }); 247 | 248 | test('should start from entered when initialEntered is set', () => { 249 | const { result, render } = renderTransitionHook({ 250 | initialProps: { initialEntered: true } 251 | }); 252 | expect(result.state).toBe(STATUS.entered); 253 | expect(render).toHaveBeenCalledTimes(1); 254 | 255 | result.toggle(); 256 | expect(result.state).toBe(STATUS.exiting); 257 | }); 258 | 259 | test('should start from unmounted when mountOnEnter is set', () => { 260 | const { result, render } = renderTransitionHook({ 261 | initialProps: { mountOnEnter: true } 262 | }); 263 | expect(result.state).toBe(STATUS.unmounted); 264 | expect(render).toHaveBeenCalledTimes(1); 265 | 266 | result.toggle(); 267 | expect(result.state).toBe(STATUS.entering); 268 | }); 269 | 270 | test('should unmount when unmountOnExit is set', () => { 271 | const { result, render } = renderTransitionHook({ 272 | initialProps: { unmountOnExit: true, onStateChange: onChange } 273 | }); 274 | 275 | result.toggle(); 276 | expect(result.state).toBe(STATUS.entering); 277 | result.endTransition(); 278 | expect(result.state).toBe(STATUS.entered); 279 | 280 | result.toggle(); 281 | expect(result.state).toBe(STATUS.exiting); 282 | result.endTransition(); 283 | expect(result.state).toBe(STATUS.unmounted); 284 | expect(onChange).toHaveBeenLastCalledWith(getOnChangeParams(STATUS.unmounted)); 285 | expect(render).toHaveBeenCalledTimes(5); 286 | }); 287 | 288 | test('returned functions have stable identity across re-renders', () => { 289 | const onChange = () => {}; 290 | const { result, rerender } = renderTransitionHook({ 291 | initialProps: { onStateChange: onChange } 292 | }); 293 | const prevToggle = result.toggleFn; 294 | const prevEndTransition = result.endTransitionFn; 295 | 296 | expect(result.state).toBe(STATUS.exited); 297 | result.toggle(); 298 | result.endTransition(); 299 | expect(result.state).toBe(STATUS.entered); 300 | rerender({ onStateChange: onChange }); 301 | 302 | expect(result.toggleFn).toBe(prevToggle); 303 | expect(result.endTransitionFn).toBe(prevEndTransition); 304 | }); 305 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getState, 3 | PRE_ENTER, 4 | ENTERING, 5 | ENTERED, 6 | PRE_EXIT, 7 | EXITING, 8 | EXITED, 9 | UNMOUNTED 10 | } from '../hooks/utils'; 11 | 12 | test('getState', () => { 13 | expect(getState(PRE_ENTER)).toEqual( 14 | expect.objectContaining({ isEnter: true, isMounted: true, isResolved: false }) 15 | ); 16 | expect(getState(ENTERING)).toEqual( 17 | expect.objectContaining({ isEnter: true, isMounted: true, isResolved: false }) 18 | ); 19 | expect(getState(ENTERED)).toEqual( 20 | expect.objectContaining({ isEnter: true, isMounted: true, isResolved: true }) 21 | ); 22 | expect(getState(PRE_EXIT)).toEqual( 23 | expect.objectContaining({ isEnter: false, isMounted: true, isResolved: false }) 24 | ); 25 | expect(getState(EXITING)).toEqual( 26 | expect.objectContaining({ isEnter: false, isMounted: true, isResolved: false }) 27 | ); 28 | expect(getState(EXITED)).toEqual( 29 | expect.objectContaining({ isEnter: false, isMounted: true, isResolved: true }) 30 | ); 31 | expect(getState(UNMOUNTED)).toEqual( 32 | expect.objectContaining({ isEnter: false, isMounted: false, isResolved: true }) 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /src/hooks/useTransitionMap.js: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useCallback } from 'react'; 2 | import { 3 | PRE_ENTER, 4 | ENTERING, 5 | ENTERED, 6 | PRE_EXIT, 7 | EXITING, 8 | startOrEnd, 9 | getState, 10 | getEndStatus, 11 | getTimeout, 12 | nextTick 13 | } from './utils'; 14 | 15 | const updateState = (key, status, setStateMap, latestStateMap, timeoutId, onChange) => { 16 | clearTimeout(timeoutId); 17 | const state = getState(status); 18 | const stateMap = new Map(latestStateMap.current); 19 | stateMap.set(key, state); 20 | setStateMap(stateMap); 21 | latestStateMap.current = stateMap; 22 | onChange && onChange({ key, current: state }); 23 | }; 24 | 25 | const useTransitionMap = ({ 26 | allowMultiple, 27 | enter = true, 28 | exit = true, 29 | preEnter, 30 | preExit, 31 | timeout, 32 | initialEntered, 33 | mountOnEnter, 34 | unmountOnExit, 35 | onStateChange: onChange 36 | } = {}) => { 37 | const [stateMap, setStateMap] = useState(new Map()); 38 | const latestStateMap = useRef(stateMap); 39 | const configMap = useRef(new Map()); 40 | const [enterTimeout, exitTimeout] = getTimeout(timeout); 41 | 42 | const setItem = useCallback( 43 | (key, config) => { 44 | const { initialEntered: _initialEntered = initialEntered } = config || {}; 45 | const status = _initialEntered ? ENTERED : startOrEnd(mountOnEnter); 46 | updateState(key, status, setStateMap, latestStateMap); 47 | configMap.current.set(key, {}); 48 | }, 49 | [initialEntered, mountOnEnter] 50 | ); 51 | 52 | const deleteItem = useCallback((key) => { 53 | const newStateMap = new Map(latestStateMap.current); 54 | if (newStateMap.delete(key)) { 55 | setStateMap(newStateMap); 56 | latestStateMap.current = newStateMap; 57 | configMap.current.delete(key); 58 | return true; 59 | } 60 | return false; 61 | }, []); 62 | 63 | const endTransition = useCallback( 64 | (key) => { 65 | const stateObj = latestStateMap.current.get(key); 66 | if (!stateObj) { 67 | process.env.NODE_ENV !== 'production' && 68 | console.error(`[React-Transition-State] invalid key: ${key}`); 69 | return; 70 | } 71 | 72 | const { timeoutId } = configMap.current.get(key); 73 | const status = getEndStatus(stateObj._s, unmountOnExit); 74 | status && updateState(key, status, setStateMap, latestStateMap, timeoutId, onChange); 75 | }, 76 | [onChange, unmountOnExit] 77 | ); 78 | 79 | const toggle = useCallback( 80 | (key, toEnter) => { 81 | const stateObj = latestStateMap.current.get(key); 82 | if (!stateObj) { 83 | process.env.NODE_ENV !== 'production' && 84 | console.error(`[React-Transition-State] invalid key: ${key}`); 85 | return; 86 | } 87 | 88 | const config = configMap.current.get(key); 89 | 90 | const transitState = (status) => { 91 | updateState(key, status, setStateMap, latestStateMap, config.timeoutId, onChange); 92 | 93 | switch (status) { 94 | case ENTERING: 95 | if (enterTimeout >= 0) 96 | config.timeoutId = setTimeout(() => endTransition(key), enterTimeout); 97 | break; 98 | 99 | case EXITING: 100 | if (exitTimeout >= 0) 101 | config.timeoutId = setTimeout(() => endTransition(key), exitTimeout); 102 | break; 103 | 104 | case PRE_ENTER: 105 | case PRE_EXIT: 106 | config.timeoutId = nextTick(transitState, status); 107 | break; 108 | } 109 | }; 110 | 111 | const enterStage = stateObj.isEnter; 112 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 113 | 114 | if (toEnter) { 115 | if (!enterStage) { 116 | transitState(enter ? (preEnter ? PRE_ENTER : ENTERING) : ENTERED); 117 | !allowMultiple && 118 | latestStateMap.current.forEach((_, _key) => _key !== key && toggle(_key, false)); 119 | } 120 | } else { 121 | if (enterStage) { 122 | transitState(exit ? (preExit ? PRE_EXIT : EXITING) : startOrEnd(unmountOnExit)); 123 | } 124 | } 125 | }, 126 | [ 127 | onChange, 128 | endTransition, 129 | allowMultiple, 130 | enter, 131 | exit, 132 | preEnter, 133 | preExit, 134 | enterTimeout, 135 | exitTimeout, 136 | unmountOnExit 137 | ] 138 | ); 139 | 140 | const toggleAll = useCallback( 141 | (toEnter) => { 142 | if (!allowMultiple && toEnter !== false) return; 143 | for (const key of latestStateMap.current.keys()) toggle(key, toEnter); 144 | }, 145 | [allowMultiple, toggle] 146 | ); 147 | 148 | return { stateMap, toggle, toggleAll, endTransition, setItem, deleteItem }; 149 | }; 150 | 151 | export { useTransitionMap }; 152 | -------------------------------------------------------------------------------- /src/hooks/useTransitionState.js: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useCallback } from 'react'; 2 | import { 3 | PRE_ENTER, 4 | ENTERING, 5 | ENTERED, 6 | PRE_EXIT, 7 | EXITING, 8 | startOrEnd, 9 | getState, 10 | getEndStatus, 11 | getTimeout, 12 | nextTick 13 | } from './utils'; 14 | 15 | const updateState = (status, setState, latestState, timeoutId, onChange) => { 16 | clearTimeout(timeoutId.current); 17 | const state = getState(status); 18 | setState(state); 19 | latestState.current = state; 20 | onChange && onChange({ current: state }); 21 | }; 22 | 23 | export const useTransitionState = ({ 24 | enter = true, 25 | exit = true, 26 | preEnter, 27 | preExit, 28 | timeout, 29 | initialEntered, 30 | mountOnEnter, 31 | unmountOnExit, 32 | onStateChange: onChange 33 | } = {}) => { 34 | const [state, setState] = useState(() => 35 | getState(initialEntered ? ENTERED : startOrEnd(mountOnEnter)) 36 | ); 37 | const latestState = useRef(state); 38 | const timeoutId = useRef(); 39 | const [enterTimeout, exitTimeout] = getTimeout(timeout); 40 | 41 | const endTransition = useCallback(() => { 42 | const status = getEndStatus(latestState.current._s, unmountOnExit); 43 | status && updateState(status, setState, latestState, timeoutId, onChange); 44 | }, [onChange, unmountOnExit]); 45 | 46 | const toggle = useCallback( 47 | (toEnter) => { 48 | const transitState = (status) => { 49 | updateState(status, setState, latestState, timeoutId, onChange); 50 | 51 | switch (status) { 52 | case ENTERING: 53 | if (enterTimeout >= 0) timeoutId.current = setTimeout(endTransition, enterTimeout); 54 | break; 55 | 56 | case EXITING: 57 | if (exitTimeout >= 0) timeoutId.current = setTimeout(endTransition, exitTimeout); 58 | break; 59 | 60 | case PRE_ENTER: 61 | case PRE_EXIT: 62 | timeoutId.current = nextTick(transitState, status); 63 | break; 64 | } 65 | }; 66 | 67 | const enterStage = latestState.current.isEnter; 68 | if (typeof toEnter !== 'boolean') toEnter = !enterStage; 69 | 70 | if (toEnter) { 71 | !enterStage && transitState(enter ? (preEnter ? PRE_ENTER : ENTERING) : ENTERED); 72 | } else { 73 | enterStage && 74 | transitState(exit ? (preExit ? PRE_EXIT : EXITING) : startOrEnd(unmountOnExit)); 75 | } 76 | }, 77 | [ 78 | endTransition, 79 | onChange, 80 | enter, 81 | exit, 82 | preEnter, 83 | preExit, 84 | enterTimeout, 85 | exitTimeout, 86 | unmountOnExit 87 | ] 88 | ); 89 | 90 | return [state, toggle, endTransition]; 91 | }; 92 | -------------------------------------------------------------------------------- /src/hooks/utils.js: -------------------------------------------------------------------------------- 1 | export const PRE_ENTER = 0; 2 | export const ENTERING = 1; 3 | export const ENTERED = 2; 4 | export const PRE_EXIT = 3; 5 | export const EXITING = 4; 6 | export const EXITED = 5; 7 | export const UNMOUNTED = 6; 8 | 9 | export const STATUS = [ 10 | 'preEnter', 11 | 'entering', 12 | 'entered', 13 | 'preExit', 14 | 'exiting', 15 | 'exited', 16 | 'unmounted' 17 | ]; 18 | 19 | export const getState = (status) => ({ 20 | _s: status, 21 | status: STATUS[status], 22 | isEnter: status < PRE_EXIT, 23 | isMounted: status !== UNMOUNTED, 24 | isResolved: status === ENTERED || status > EXITING 25 | }); 26 | 27 | export const startOrEnd = (unmounted) => (unmounted ? UNMOUNTED : EXITED); 28 | 29 | export const getEndStatus = (status, unmountOnExit) => { 30 | switch (status) { 31 | case ENTERING: 32 | case PRE_ENTER: 33 | return ENTERED; 34 | 35 | case EXITING: 36 | case PRE_EXIT: 37 | return startOrEnd(unmountOnExit); 38 | } 39 | }; 40 | 41 | export const getTimeout = (timeout) => 42 | typeof timeout === 'object' ? [timeout.enter, timeout.exit] : [timeout, timeout]; 43 | 44 | export const nextTick = (transitState, status) => 45 | setTimeout(() => { 46 | // Reading document.body.offsetTop can force browser to repaint before transition to the next state 47 | isNaN(document.body.offsetTop) || transitState(status + 1); 48 | }, 0); 49 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | useTransitionState, 3 | useTransitionState as useTransition, 4 | useTransitionState as default 5 | } from './hooks/useTransitionState'; 6 | export { useTransitionMap } from './hooks/useTransitionMap'; 7 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type TransitionStatus = 2 | | 'preEnter' 3 | | 'entering' 4 | | 'entered' 5 | | 'preExit' 6 | | 'exiting' 7 | | 'exited' 8 | | 'unmounted'; 9 | 10 | export type TransitionState = Readonly<{ 11 | status: TransitionStatus; 12 | isMounted: boolean; 13 | isEnter: boolean; 14 | isResolved: boolean; 15 | }>; 16 | 17 | export interface TransitionOptions { 18 | initialEntered?: boolean; 19 | mountOnEnter?: boolean; 20 | unmountOnExit?: boolean; 21 | preEnter?: boolean; 22 | preExit?: boolean; 23 | enter?: boolean; 24 | exit?: boolean; 25 | timeout?: number | { enter?: number; exit?: number }; 26 | onStateChange?: (event: { current: TransitionState }) => void; 27 | } 28 | 29 | export interface TransitionItemOptions { 30 | initialEntered?: boolean; 31 | } 32 | 33 | export interface TransitionMapOptions extends Omit { 34 | allowMultiple?: boolean; 35 | onStateChange?: (event: { key: K; current: TransitionState }) => void; 36 | } 37 | 38 | export type TransitionResult = [TransitionState, (toEnter?: boolean) => void, () => void]; 39 | 40 | export interface TransitionMapResult { 41 | stateMap: ReadonlyMap; 42 | toggle: (key: K, toEnter?: boolean) => void; 43 | toggleAll: (toEnter?: boolean) => void; 44 | endTransition: (key: K) => void; 45 | setItem: (key: K, options?: TransitionItemOptions) => void; 46 | deleteItem: (key: K) => boolean; 47 | } 48 | 49 | export const useTransitionState: (options?: TransitionOptions) => TransitionResult; 50 | 51 | export const useTransitionMap: (options?: TransitionMapOptions) => TransitionMapResult; 52 | 53 | export { 54 | /** 55 | * @deprecated The `useTransition` alias will be removed in v3.0.0. Use `useTransitionState` instead. 56 | */ 57 | useTransitionState as useTransition 58 | }; 59 | 60 | /** 61 | * @deprecated The default export will be removed in v3.0.0. Use the named export `useTransitionState` instead. 62 | */ 63 | export default useTransitionState; 64 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------