27 |
28 | ## Idea
29 |
30 | This is almost `react transition group`, but for state management...
31 |
32 | Why people [react transition group](https://github.com/reactjs/react-transition-group)? Because it 1) **takes a pause**
33 | between steps letting classNames be applied and 2) **keeps children** after you remove them, to perform a fade
34 | animation.
35 |
36 | We are doing the same, but using state.
37 |
38 | It's all about tracking what the value `would be`, what is `right now`, and what it `was`.
39 | We call this ContinuousState
40 |
41 | ### ContinuousState
42 |
43 | - `future` - the next value. The value you just set.
44 | - `present` - to be synchronized with the `future` "later"
45 | - `past` - to be synchronized with the `present` "later"
46 |
47 | and
48 |
49 | - `defined` - an indication that any of future, past or present are truthy.
50 |
51 | # API
52 |
53 | ## useContinuousState(value, options)
54 |
55 | - `value` - a value to assign to the `future`
56 | - `options`
57 | - `delayPresent` - time in ms between `future` becoming `present`
58 | - `delayPast` - time in ms between `present` becoming `past`. For transitions, it usually equals to exist animation duration
59 | - `initialValue` - a value to be set as initial to the `past` and `present`. `future` is always equal to the `value` given as a first arg
60 |
61 | ## useScatteredContinuousState(value, options)
62 |
63 | Call signature is equal to `useContinuousState`, returns an object with extra property `DefinePresent`. See example below.
64 |
65 | ## Usage
66 |
67 | ### Problem statement
68 |
69 | Let's imagine you have a switch. Which controls visibility of something, but you also want to add some animation.
70 |
71 | Let's handle these cases separately:
72 |
73 | ```typescript jsx
74 | const App = () => {
75 | const [on, setOn] = useState(false);
76 |
77 | return (
78 |
79 |
80 | // would be instanly hidden and shown
81 | {on && }
82 | // would be animated, but would be ALWAYS rendered
83 | }
84 |
85 | );
86 | };
87 | ```
88 |
89 | Now let's imagine you want to **not render Content** _when_ it's not visible and not required.
90 |
91 | Ok, "when is this _when_"?
92 |
93 | - render `ContentWithAnimation` when it is _about_ to be displayed
94 | - render `ContentWithAnimation` when it is displayed
95 | - render `ContentWithAnimation` when it is no longer visible, but still animating toward hidden state
96 |
97 | ```typescript jsx
98 | import { ContinuousContainer, useContinuousState } from '@theuiteam/continuous-container';
99 |
100 | const App = () => {
101 | const [on, setOn] = useState(false);
102 | const continuousState = useContinuousState(on);
103 |
104 | return (
105 |
106 |
107 | {/*render if any of past/preset/future is set to true*/}
108 | {continuousState.defined && (
109 |
110 | // wire the "present" state
111 | )}
112 | {/* or */}
113 |
114 | {
115 | ({past, present, future}) => (past || present || future) &&
116 | // ^^ use the "present" value
117 | }
118 |
119 |
120 | );
121 | };
122 | ```
123 |
124 | ## Scattered
125 |
126 | There are more sophisticated situations, when **setting up** something to display does not mean "display". Lazy loading
127 | is a good case
128 |
129 | ```tsx
130 | const App = () => {
131 | const continuousState = useContinuousState(on);
132 | return continuousState.defined && ;
133 | };
134 | ```
135 |
136 | In such case ContinuousState will update from `future` to `present` before `LazyLoadedContentWithAnimation` component is
137 | loaded, breaking a connection between states.
138 |
139 | In order to handle this problem one might need to _tap_ into rendering process using `useScatteredContinuousState`
140 |
141 | ```tsx
142 | const continuousState = useScatteredContinuousState(on);
143 | return (
144 | continuousState.defined && (
145 |
146 |
147 |
148 | {/*this component will advance ContinuousState once rendered*/}
149 |
150 |
151 | )
152 | );
153 | ```
154 |
155 | For readability purposes we recommend putting DefinePresent to a separate slot different from `children`.
156 |
157 | ```tsx
158 | } />
159 | ```
160 |
161 | ###### ⚠️⚠️⚠️⚠️⚠️⚠️⚠️
162 |
163 | The following code will NOT work as `DefinePresent` will be rendered instantly, even if suspense will be in fallback
164 |
165 | ```tsx
166 |
167 | // will not be rendred until ready
168 |
169 | // will be rendered too early
170 |
171 |
172 | ```
173 |
174 | # See also
175 |
176 | - [Phased](https://github.com/theKashey/recondition#phased) Container from a `recondition` library
177 |
178 | # License
179 |
180 | MIT
181 |
--------------------------------------------------------------------------------
/__tests__/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {useContinuousState, useScatteredContinuousState} from '../src';
3 | import {render, fireEvent, act} from '@testing-library/react';
4 | import {FC, lazy, Suspense, useState} from 'react';
5 |
6 | describe('useContinuousState', () => {
7 | describe('initialization', () => {
8 | it('control flow - default', () => {
9 | const stateCallback = jest.fn();
10 | const TestSuite = () => {
11 | const state = useContinuousState(true);
12 | stateCallback(state.past, state.present, state.future);
13 |
14 | return null;
15 | };
16 |
17 | render();
18 | expect(stateCallback).toHaveBeenCalledTimes(1);
19 | expect(stateCallback).toHaveBeenNthCalledWith(1, true, true, true);
20 | });
21 | it('control flow - init true', () => {
22 | const stateCallback = jest.fn();
23 | const TestSuite = () => {
24 | const state = useContinuousState(true, {initialValue: true});
25 | stateCallback(state.past, state.present, state.future);
26 |
27 | return null;
28 | };
29 |
30 | render();
31 | expect(stateCallback).toHaveBeenCalledTimes(1);
32 | expect(stateCallback).toHaveBeenNthCalledWith(1, true, true, true);
33 | });
34 | it('control flow - init false', () => {
35 | const stateCallback = jest.fn();
36 | const TestSuite = () => {
37 | const state = useContinuousState(true, {initialValue: false});
38 | stateCallback(state.past, state.present, state.future);
39 |
40 | return null;
41 | };
42 |
43 | render();
44 | expect(stateCallback).toHaveBeenCalledTimes(2);
45 | expect(stateCallback).toHaveBeenNthCalledWith(1, false, false, false);
46 | expect(stateCallback).toHaveBeenNthCalledWith(2, false, false, true);
47 | });
48 | });
49 |
50 | describe('advance', () => {
51 | const advance = () => {
52 | act(() => {
53 | jest.advanceTimersByTime(1)
54 | })
55 | };
56 |
57 | it('off-on-off', () => {
58 | jest.useFakeTimers();
59 |
60 | const TestSuite = () => {
61 | const [on, setOn] = useState(false);
62 | const state = useContinuousState(on);
63 |
64 | return (
65 |
70 | );
71 | };
72 |
73 | const probe = render();
74 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
75 | // -> on
76 | fireEvent.click(probe.getByText('toggle fff'));
77 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
78 | // -> advance | set present
79 | advance();
80 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
81 |
82 | // -> off
83 | fireEvent.click(probe.getByText('toggle ftt'));
84 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
85 | // -> advance | set present
86 | advance();
87 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
88 | });
89 |
90 | it('scattered', async () => {
91 | jest.useFakeTimers();
92 |
93 | let resolve:any;
94 | const lazyPromise = new Promise<{default:FC}>(r => {
95 | resolve=r;
96 | })
97 |
98 | const LazyComponent = lazy(() => lazyPromise)
99 |
100 | const TestSuite = () => {
101 | const [on, setOn] = useState(false);
102 | const state = useScatteredContinuousState(on);
103 |
104 | return (
105 | <>
106 |
111 | {state.defined && (
112 |
113 | }/>
114 |
115 | )}
116 | >
117 | );
118 | };
119 |
120 | const probe = render();
121 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`""`);
122 | // -> on
123 | fireEvent.click(probe.getByText('toggle fff'));
124 | expect(probe.baseElement.innerHTML).toMatchInlineSnapshot(`"