Skip to content

Commit 8ec9e43

Browse files
Merge pull request #227 from cleandart/CPLAT-8035-useeffect-hook
CPLAT-8035 Implement/Expose useEffect Hook
2 parents 424d531 + e234b38 commit 8ec9e43

File tree

4 files changed

+243
-16
lines changed

4 files changed

+243
-16
lines changed

example/test/function_component_test.dart

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,32 @@ import 'package:react/react.dart' as react;
55
import 'package:react/react_dom.dart' as react_dom;
66
import 'package:react/react_client.dart';
77

8-
var useStateTestFunctionComponent = react.registerFunctionComponent(UseStateTestComponent, displayName: 'useStateTest');
9-
10-
UseStateTestComponent(Map props) {
11-
final count = useState(0);
8+
var hookTestFunctionComponent = react.registerFunctionComponent(HookTestComponent, displayName: 'useStateTest');
9+
10+
HookTestComponent(Map props) {
11+
final count = useState(1);
12+
final evenOdd = useState('even');
13+
14+
useEffect(() {
15+
if (count.value % 2 == 0) {
16+
print('count changed to ' + count.value.toString());
17+
evenOdd.set('even');
18+
} else {
19+
print('count changed to ' + count.value.toString());
20+
evenOdd.set('odd');
21+
}
22+
return () {
23+
print('count is changing... do some cleanup if you need to');
24+
};
25+
26+
/// This dependency prevents the effect from running every time [evenOdd.value] changes.
27+
}, [count.value]);
1228

1329
return react.div({}, [
14-
count.value,
15-
react.button({'onClick': (_) => count.set(0), 'key': 'ust1'}, ['Reset']),
30+
react.button({'onClick': (_) => count.set(1), 'key': 'ust1'}, ['Reset']),
1631
react.button({'onClick': (_) => count.setWithUpdater((prev) => prev + 1), 'key': 'ust2'}, ['+']),
32+
react.br({'key': 'ust3'}),
33+
react.p({'key': 'ust4'}, ['${count.value} is ${evenOdd.value}']),
1734
]);
1835
}
1936

@@ -107,8 +124,8 @@ void main() {
107124
'key': 'fctf'
108125
}, [
109126
react.h1({'key': 'functionComponentTestLabel'}, ['Function Component Tests']),
110-
react.h2({'key': 'useStateTestLabel'}, ['useState Hook Test']),
111-
useStateTestFunctionComponent({
127+
react.h2({'key': 'useStateTestLabel'}, ['useState & useEffect Hook Test']),
128+
hookTestFunctionComponent({
112129
'key': 'useStateTest',
113130
}, []),
114131
react.br({'key': 'br'}),

lib/hooks.dart

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,62 @@ StateHook<T> useState<T>(T initialValue) => StateHook(initialValue);
102102
/// Learn more: <https://reactjs.org/docs/hooks-reference.html#lazy-initial-state>.
103103
StateHook<T> useStateLazy<T>(T init()) => StateHook.lazy(init);
104104

105+
/// Runs [sideEffect] after every completed render of a [DartFunctionComponent].
106+
///
107+
/// If [dependencies] are given, [sideEffect] will only run if one of the [dependencies] have changed.
108+
/// [sideEffect] may return a cleanup function that is run before the component unmounts or re-renders.
109+
///
110+
/// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html):
111+
/// >
112+
/// > * Only call Hooks at the top level.
113+
/// > * Only call Hooks from inside a [DartFunctionComponent].
114+
///
115+
/// __Example__:
116+
///
117+
/// ```
118+
/// UseEffectTestComponent(Map props) {
119+
/// final count = useState(1);
120+
/// final evenOdd = useState('even');
121+
///
122+
/// useEffect(() {
123+
/// if (count.value % 2 == 0) {
124+
/// evenOdd.set('even');
125+
/// } else {
126+
/// evenOdd.set('odd');
127+
/// }
128+
/// return () {
129+
/// print('count is changing... do some cleanup if you need to');
130+
/// };
131+
///
132+
/// // This dependency prevents the effect from running every time [evenOdd.value] changes.
133+
/// }, [count.value]);
134+
///
135+
/// return react.div({}, [
136+
/// react.p({}, ['${count.value} is ${evenOdd.value}']),
137+
/// react.button({'onClick': (_) => count.set(count.value + 1)}, ['+']),
138+
/// ]);
139+
/// }
140+
/// ```
141+
///
142+
/// See: <https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects>.
143+
void useEffect(dynamic Function() sideEffect, [List<Object> dependencies]) {
144+
var wrappedSideEffect = allowInterop(() {
145+
var result = sideEffect();
146+
if (result is Function) {
147+
return allowInterop(result);
148+
}
149+
150+
/// When no cleanup function is returned, [sideEffect] returns undefined.
151+
return jsUndefined;
152+
});
153+
154+
if (dependencies != null) {
155+
return React.useEffect(wrappedSideEffect, dependencies);
156+
} else {
157+
return React.useEffect(wrappedSideEffect);
158+
}
159+
}
160+
105161
/// Returns a memoized version of [callback] that only changes if one of the [dependencies] has changed.
106162
///
107163
/// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html):

lib/react_client/react_interop.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ abstract class React {
4747
external static ReactClass get Fragment;
4848

4949
external static List<dynamic> useState(dynamic value);
50+
external static void useEffect(dynamic Function() sideEffect, [List<Object> dependencies]);
5051
external static Function useCallback(Function callback, List dependencies);
5152
external static ReactContext useContext(ReactContext context);
5253
}
@@ -488,6 +489,11 @@ class JsError {
488489
@JS('_jsNull')
489490
external get jsNull;
490491

492+
/// A JS variable that can be used with Dart interop in order to force returning a JavaScript `undefined`.
493+
/// Use this if dart2js is possibly converting Dart `undefined` into `null`.
494+
@JS('_jsUndefined')
495+
external get jsUndefined;
496+
491497
/// Throws the error passed to it from Javascript.
492498
/// This allows us to catch the error in dart which re-dartifies the js errors/exceptions.
493499
@alwaysThrows

test/hooks_test.dart

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,6 @@ main() {
7171
react_dom.render(UseStateTest({}), mountNode);
7272
});
7373

74-
tearDownAll(() {
75-
UseStateTest = null;
76-
});
77-
7874
test('initializes state correctly', () {
7975
expect(countRef.text, '0');
8076
});
@@ -94,6 +90,162 @@ main() {
9490
});
9591
});
9692

93+
group('useEffect -', () {
94+
ReactDartFunctionComponentFactoryProxy UseEffectTest;
95+
ButtonElement countButtonRef;
96+
DivElement countRef;
97+
DivElement mountNode;
98+
int useEffectCallCount;
99+
int useEffectCleanupCallCount;
100+
int useEffectWithDepsCallCount;
101+
int useEffectCleanupWithDepsCallCount;
102+
int useEffectWithDepsCallCount2;
103+
int useEffectCleanupWithDepsCallCount2;
104+
int useEffectWithEmptyDepsCallCount;
105+
int useEffectCleanupWithEmptyDepsCallCount;
106+
107+
setUpAll(() {
108+
mountNode = new DivElement();
109+
useEffectCallCount = 0;
110+
useEffectCleanupCallCount = 0;
111+
useEffectWithDepsCallCount = 0;
112+
useEffectCleanupWithDepsCallCount = 0;
113+
useEffectWithDepsCallCount2 = 0;
114+
useEffectCleanupWithDepsCallCount2 = 0;
115+
useEffectWithEmptyDepsCallCount = 0;
116+
useEffectCleanupWithEmptyDepsCallCount = 0;
117+
118+
UseEffectTest = react.registerFunctionComponent((Map props) {
119+
final count = useState(0);
120+
final countDown = useState(0);
121+
122+
useEffect(() {
123+
useEffectCallCount++;
124+
return () {
125+
useEffectCleanupCallCount++;
126+
};
127+
});
128+
129+
useEffect(() {
130+
useEffectWithDepsCallCount++;
131+
return () {
132+
useEffectCleanupWithDepsCallCount++;
133+
};
134+
}, [count.value]);
135+
136+
useEffect(() {
137+
useEffectWithDepsCallCount2++;
138+
return () {
139+
useEffectCleanupWithDepsCallCount2++;
140+
};
141+
}, [countDown.value]);
142+
143+
useEffect(() {
144+
useEffectWithEmptyDepsCallCount++;
145+
return () {
146+
useEffectCleanupWithEmptyDepsCallCount++;
147+
};
148+
}, []);
149+
150+
return react.div({}, [
151+
react.div({
152+
'ref': (ref) {
153+
countRef = ref;
154+
},
155+
}, [
156+
count.value
157+
]),
158+
react.button({
159+
'onClick': (_) {
160+
count.set(count.value + 1);
161+
},
162+
'ref': (ref) {
163+
countButtonRef = ref;
164+
},
165+
}, [
166+
'+'
167+
]),
168+
]);
169+
});
170+
171+
react_dom.render(UseEffectTest({}), mountNode);
172+
});
173+
174+
test('side effect (no dependency list) is called after the first render', () {
175+
expect(countRef.text, '0');
176+
177+
expect(useEffectCallCount, 1);
178+
expect(useEffectCleanupCallCount, 0, reason: 'component has not been unmounted or re-rendered');
179+
});
180+
181+
test('side effect (with dependency list) is called after the first render', () {
182+
expect(useEffectWithDepsCallCount, 1);
183+
expect(useEffectCleanupWithDepsCallCount, 0, reason: 'component has not been unmounted or re-rendered');
184+
185+
expect(useEffectWithDepsCallCount2, 1);
186+
expect(useEffectCleanupWithDepsCallCount2, 0, reason: 'component has not been unmounted or re-rendered');
187+
});
188+
189+
test('side effect (with empty dependency list) is called after the first render', () {
190+
expect(useEffectWithEmptyDepsCallCount, 1);
191+
expect(useEffectCleanupWithEmptyDepsCallCount, 0, reason: 'component has not been unmounted or re-rendered');
192+
});
193+
194+
group('after state change,', () {
195+
setUpAll(() {
196+
react_test_utils.Simulate.click(countButtonRef);
197+
});
198+
199+
test('side effect (no dependency list) is called again', () {
200+
expect(countRef.text, '1');
201+
202+
expect(useEffectCallCount, 2);
203+
expect(useEffectCleanupCallCount, 1, reason: 'cleanup called before re-render');
204+
});
205+
206+
test('side effect (with dependency list) is called again if one of its dependencies changed', () {
207+
expect(useEffectWithDepsCallCount, 2, reason: 'count.value changed');
208+
expect(useEffectCleanupWithDepsCallCount, 1, reason: 'cleanup called before re-render');
209+
});
210+
211+
test('side effect (with dependency list) is not called again if none of its dependencies changed', () {
212+
expect(useEffectWithDepsCallCount2, 1, reason: 'countDown.value did not change');
213+
expect(useEffectCleanupWithDepsCallCount2, 0,
214+
reason: 'cleanup not called because countDown.value did not change');
215+
});
216+
217+
test('side effect (with empty dependency list) is not called again', () {
218+
expect(useEffectWithEmptyDepsCallCount, 1,
219+
reason: 'side effect is only called once for empty dependency list');
220+
expect(useEffectCleanupWithEmptyDepsCallCount, 0, reason: 'component has not been unmounted');
221+
});
222+
});
223+
224+
group('after component is unmounted,', () {
225+
setUpAll(() {
226+
react_dom.unmountComponentAtNode(mountNode);
227+
});
228+
229+
test('cleanup (no dependency list) is called', () {
230+
expect(useEffectCallCount, 2, reason: 'side effect not called on unmount');
231+
expect(useEffectCleanupCallCount, 2);
232+
});
233+
234+
test('cleanup (with dependency list) is called', () {
235+
expect(useEffectWithDepsCallCount, 2, reason: 'side effect not called on unmount');
236+
expect(useEffectCleanupWithDepsCallCount, 2);
237+
238+
expect(useEffectWithDepsCallCount2, 1, reason: 'side effect not called on unmount');
239+
expect(useEffectCleanupWithDepsCallCount2, 1);
240+
});
241+
242+
test('cleanup (with empty dependency list) is called', () {
243+
expect(useEffectWithEmptyDepsCallCount, 1, reason: 'side effect not called on unmount');
244+
expect(useEffectCleanupWithEmptyDepsCallCount, 1);
245+
});
246+
});
247+
});
248+
97249
group('useCallback -', () {
98250
ReactDartFunctionComponentFactoryProxy UseCallbackTest;
99251
DivElement deltaRef;
@@ -166,10 +318,6 @@ main() {
166318
react_dom.render(UseCallbackTest({}), mountNode);
167319
});
168320

169-
tearDownAll(() {
170-
UseCallbackTest = null;
171-
});
172-
173321
test('callback is called correctly', () {
174322
expect(countRef.text, '0');
175323
expect(deltaRef.text, '1');

0 commit comments

Comments
 (0)