diff --git a/example/test/function_component_test.dart b/example/test/function_component_test.dart index 9084d063..6a8acad3 100644 --- a/example/test/function_component_test.dart +++ b/example/test/function_component_test.dart @@ -183,6 +183,53 @@ UseRefTestComponent(Map props) { ]); } +int fibonacci(int n) { + if (n <= 1) { + return 1; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +final useMemoTestFunctionComponent = react.registerFunctionComponent(UseMemoTestComponent, displayName: 'useMemoTest'); + +UseMemoTestComponent(Map props) { + final reRender = useState(0); + final count = useState(35); + + final fib = useMemo( + () { + print('calculating fibonacci...'); + return fibonacci(count.value); + }, + + /// This dependency prevents [fib] from being re-calculated every time the component re-renders. + [count.value], + ); + + return react.Fragment({}, [ + react.div({'key': 'div'}, ['Fibonacci of ${count.value} is $fib']), + react.button({'key': 'button1', 'onClick': (_) => count.setWithUpdater((prev) => prev + 1)}, ['+']), + react.button({'key': 'button2', 'onClick': (_) => reRender.setWithUpdater((prev) => prev + 1)}, ['re-render']), + ]); +} + +final useMemoTestFunctionComponent2 = + react.registerFunctionComponent(UseMemoTestComponent2, displayName: 'useMemoTest2'); + +UseMemoTestComponent2(Map props) { + final reRender = useState(0); + final count = useState(35); + + print('calculating fibonacci...'); + final fib = fibonacci(count.value); + + return react.Fragment({}, [ + react.div({'key': 'div'}, ['Fibonacci of ${count.value} is ${fib}']), + react.button({'key': 'button1', 'onClick': (_) => count.setWithUpdater((prev) => prev + 1)}, ['+']), + react.button({'key': 'button2', 'onClick': (_) => reRender.setWithUpdater((prev) => prev + 1)}, ['re-render']), + ]); +} + void main() { setClientConfiguration(); @@ -218,6 +265,17 @@ void main() { useRefTestFunctionComponent({ 'key': 'useRefTest', }, []), + react.h2({'key': 'useMemoTestLabel'}, ['useMemo Hook Test']), + react.h6({'key': 'h61'}, ['With useMemo:']), + useMemoTestFunctionComponent({ + 'key': 'useMemoTest', + }, []), + react.br({'key': 'br4'}), + react.br({'key': 'br5'}), + react.h6({'key': 'h62'}, ['Without useMemo (notice calculation done on every render):']), + useMemoTestFunctionComponent2({ + 'key': 'useMemoTest2', + }, []), ]), querySelector('#content')); } diff --git a/lib/hooks.dart b/lib/hooks.dart index ea045635..0cb36774 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -396,3 +396,36 @@ T useContext(Context context) => ContextHelpers.unjsifyNewContext(React.us /// /// Learn more: . Ref useRef([T initialValue]) => Ref.useRefInit(initialValue); + +/// Returns a memoized version of the return value of [createFunction]. +/// +/// If one of the [dependencies] has changed, [createFunction] is run during rendering of the [DartFunctionComponent]. +/// This optimization helps to avoid expensive calculations on every render. +/// +/// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html): +/// > +/// > * Only call Hooks at the top level. +/// > * Only call Hooks from inside a [DartFunctionComponent]. +/// +/// __Example__: +/// ``` +/// UseMemoTestComponent(Map props) { +/// final count = useState(0); +/// +/// final fib = useMemo( +/// () => fibonacci(count.value), +/// +/// /// This dependency prevents [fib] from being re-calculated every time the component re-renders. +/// [count.value], +/// ); +/// +/// return react.Fragment({}, [ +/// react.div({}, ['Fibonacci of ${count.value} is $fib']), +/// react.button({'onClick': (_) => count.setWithUpdater((prev) => prev + 1)}, ['+']), +/// ]); +/// } +/// ``` +/// +/// Learn more: . +T useMemo(T Function() createFunction, [List dependencies]) => + React.useMemo(allowInterop(createFunction), dependencies); diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 3b8314e2..8119bbcf 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -53,6 +53,7 @@ abstract class React { external static Function useCallback(Function callback, List dependencies); external static ReactContext useContext(ReactContext context); external static JsRef useRef([dynamic initialValue]); + external static dynamic useMemo(dynamic Function() createFunction, [List dependencies]); } /// Creates a [Ref] object that can be attached to a [ReactElement] via the ref prop. diff --git a/test/hooks_test.dart b/test/hooks_test.dart index da3ceb12..b664263f 100644 --- a/test/hooks_test.dart +++ b/test/hooks_test.dart @@ -26,7 +26,7 @@ main() { ButtonElement setWithUpdaterButtonRef; setUpAll(() { - var mountNode = new DivElement(); + var mountNode = DivElement(); UseStateTest = react.registerFunctionComponent((Map props) { final text = useStateLazy(() { @@ -105,7 +105,7 @@ main() { int useEffectCleanupWithEmptyDepsCallCount; setUpAll(() { - mountNode = new DivElement(); + mountNode = DivElement(); useEffectCallCount = 0; useEffectCleanupCallCount = 0; useEffectWithDepsCallCount = 0; @@ -268,7 +268,7 @@ main() { } setUpAll(() { - var mountNode = new DivElement(); + var mountNode = DivElement(); UseReducerTest = react.registerFunctionComponent((Map props) { final state = useReducer(reducer, { @@ -362,7 +362,7 @@ main() { } setUpAll(() { - var mountNode = new DivElement(); + var mountNode = DivElement(); UseReducerTest = react.registerFunctionComponent((Map props) { final ReducerHook state = useReducerLazy(reducer2, props['initialCount'], initializeCount); @@ -435,7 +435,7 @@ main() { ButtonElement incrementDeltaButtonRef; setUpAll(() { - var mountNode = new DivElement(); + var mountNode = DivElement(); UseCallbackTest = react.registerFunctionComponent((Map props) { final count = useState(0); @@ -519,7 +519,7 @@ main() { expect(countRef.text, '3', reason: 'still increments by 1 because delta not in dependency list'); }); - test('callback stays the same if state not in dependency list', () { + test('callback updates if state is in dependency list', () { react_test_utils.Simulate.click(incrementWithDepButtonRef); expect(countRef.text, '5', reason: 'increments by 2 because delta updated'); }); @@ -589,7 +589,7 @@ main() { }); group('useRef -', () { - var mountNode = new DivElement(); + var mountNode = DivElement(); ReactDartFunctionComponentFactoryProxy UseRefTest; ButtonElement reRenderButton; var noInitRef; @@ -666,6 +666,130 @@ main() { }); }); }); + + group('useMemo -', () { + ReactDartFunctionComponentFactoryProxy UseMemoTest; + StateHook count; + ButtonElement reRenderButtonRef; + ButtonElement incrementButtonRef; + + // Count how many times createFunction() is called for each variation of dependencies. + int createFunctionCallCountWithDeps = 0; + int createFunctionCallCountNoDeps = 0; + int createFunctionCallCountEmptyDeps = 0; + + // Keeps track of return value of useMemo() for each variation of dependencies. + int returnValueWithDeps; + int returnValueNoDeps; + int returnValueEmptyDeps; + + int fibonacci(int n) { + if (n <= 1) { + return 1; + } + return fibonacci(n - 1) + fibonacci(n - 2); + } + + setUpAll(() { + final mountNode = DivElement(); + + UseMemoTest = react.registerFunctionComponent((Map props) { + final reRender = useState(0); + count = useState(5); + + returnValueWithDeps = useMemo( + () { + createFunctionCallCountWithDeps++; + return fibonacci(count.value); + }, + [count.value], + ); + + returnValueNoDeps = useMemo( + () { + createFunctionCallCountNoDeps++; + return fibonacci(count.value); + }, + ); + + returnValueEmptyDeps = useMemo( + () { + createFunctionCallCountEmptyDeps++; + return fibonacci(count.value); + }, + [], + ); + + return react.Fragment({}, [ + react.button( + {'ref': (ref) => incrementButtonRef = ref, 'onClick': (_) => count.setWithUpdater((prev) => prev + 1)}, + ['+']), + react.button({ + 'ref': (ref) => reRenderButtonRef = ref, + 'onClick': (_) => reRender.setWithUpdater((prev) => prev + 1) + }, [ + 're-render' + ]), + ]); + }); + + react_dom.render(UseMemoTest({}), mountNode); + }); + + test('correctly initializes memoized value', () { + expect(count.value, 5); + + expect(returnValueWithDeps, 8); + expect(returnValueNoDeps, 8); + expect(returnValueEmptyDeps, 8); + + expect(createFunctionCallCountWithDeps, 1); + expect(createFunctionCallCountNoDeps, 1); + expect(createFunctionCallCountEmptyDeps, 1); + }); + + group('after depending state changes,', () { + setUpAll(() { + react_test_utils.Simulate.click(incrementButtonRef); + }); + + test('createFunction does not run if state not in dependency list', () { + expect(returnValueEmptyDeps, 8); + + expect(createFunctionCallCountEmptyDeps, 1, reason: 'count.value is not in dependency list'); + }); + + test('createFunction re-runs if state is in dependency list or if there is no dependency list', () { + expect(returnValueWithDeps, 13); + expect(returnValueNoDeps, 13); + + expect(createFunctionCallCountWithDeps, 2, reason: 'count.value is in dependency list'); + expect(createFunctionCallCountNoDeps, 2, + reason: 'createFunction runs on every render because there is no dependency list'); + }); + }); + + group('after component re-renders,', () { + setUpAll(() { + react_test_utils.Simulate.click(reRenderButtonRef); + }); + + test('createFunction re-runs if there is no dependency list', () { + expect(returnValueNoDeps, 13, reason: 'count.value stayed the same so the same value is returned'); + + expect(createFunctionCallCountNoDeps, 3, + reason: 'createFunction runs on every render because there is no dependency list'); + }); + + test('createFunction does not run if there is a dependency list', () { + expect(returnValueEmptyDeps, 8); + expect(returnValueWithDeps, 13); + + expect(createFunctionCallCountEmptyDeps, 1, reason: 'no dependency changed'); + expect(createFunctionCallCountWithDeps, 2, reason: 'no dependency changed'); + }); + }); + }); }); }