├── .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 | [](https://www.npmjs.com/package/react-transition-state) [](https://www.npmjs.com/package/react-transition-state) [](https://bundlephobia.com/package/react-transition-state) [](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 |  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 |
toggle()}>toggle
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 &&
toggle(true)}>Show Message }
109 | {isMounted && (
110 |
111 | This message is being transitioned in and out of the DOM.
112 | toggle(false)}>Close
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 | [](https://bundlephobia.com/package/react-transition-group) | ✅ [](https://bundlephobia.com/package/react-transition-state) |
167 | | Dependency count | [](https://www.npmjs.com/package/react-transition-group?activeTab=dependencies) | ✅ [](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 | You need to enable JavaScript to run this app.
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 |
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 |
20 | Unmount after hiding
21 | setUnmountOnExit(e.target.checked)}
25 | />
26 |
27 |
toggle()}>
28 | {status === 'entering' || status === 'entered' ? 'Hide' : 'Show'}
29 |
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 |
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 | toggle(true)}>
40 | Show Message
41 |
42 | )}
43 | {isMounted && (
44 |
45 | status: {status}
46 | This message is being transitioned in and out of the DOM.
47 | toggle(false)}>
48 | Close
49 |
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 |
40 | {children}
41 |
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 | toggle(nextItemKey)}>
56 | {children}
57 |
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 |
--------------------------------------------------------------------------------