diff --git a/example/test/function_component_test.dart b/example/test/function_component_test.dart index fd038705..609bc6a2 100644 --- a/example/test/function_component_test.dart +++ b/example/test/function_component_test.dart @@ -34,6 +34,55 @@ HookTestComponent(Map props) { ]); } +var useReducerTestFunctionComponent = + react.registerFunctionComponent(UseReducerTestComponent, displayName: 'useReducerTest'); + +Map initializeCount(int initialValue) { + return {'count': initialValue}; +} + +Map reducer(Map state, Map action) { + switch (action['type']) { + case 'increment': + return {...state, 'count': state['count'] + 1}; + case 'decrement': + return {...state, 'count': state['count'] - 1}; + case 'reset': + return initializeCount(action['payload']); + default: + return state; + } +} + +UseReducerTestComponent(Map props) { + final ReducerHook state = useReducerLazy(reducer, props['initialCount'], initializeCount); + + return react.Fragment({}, [ + state.state['count'], + react.button({ + 'key': 'urt1', + 'onClick': (_) => state.dispatch({'type': 'increment'}) + }, [ + '+' + ]), + react.button({ + 'key': 'urt2', + 'onClick': (_) => state.dispatch({'type': 'decrement'}) + }, [ + '-' + ]), + react.button({ + 'key': 'urt3', + 'onClick': (_) => state.dispatch({ + 'type': 'reset', + 'payload': props['initialCount'], + }) + }, [ + 'reset' + ]), + ]); +} + var useCallbackTestFunctionComponent = react.registerFunctionComponent(UseCallbackTestComponent, displayName: 'useCallbackTest'); @@ -128,11 +177,13 @@ void main() { hookTestFunctionComponent({ 'key': 'useStateTest', }, []), - react.br({'key': 'br'}), + react.br({'key': 'br1'}), react.h2({'key': 'useCallbackTestLabel'}, ['useCallback Hook Test']), useCallbackTestFunctionComponent({ 'key': 'useCallbackTest', }, []), + react.br({'key': 'br2'}), + react.h2({'key': 'useContextTestLabel'}, ['useContext Hook Test']), newContextProviderComponent({ 'key': 'provider' }, [ @@ -140,6 +191,12 @@ void main() { 'key': 'useContextTest', }, []), ]), + react.br({'key': 'br3'}), + react.h2({'key': 'useReducerTestLabel'}, ['useReducer Hook Test']), + useReducerTestFunctionComponent({ + 'key': 'useReducerTest', + 'initialCount': 10, + }, []), ]), querySelector('#content')); } diff --git a/lib/hooks.dart b/lib/hooks.dart index 31a904c6..0003bc62 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -158,6 +158,147 @@ void useEffect(dynamic Function() sideEffect, [List dependencies]) { } } +/// The return value of [useReducer]. +/// +/// The current state is available via [state] and action dispatcher is available via [dispatch]. +/// +/// > __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]. +/// +/// Learn more: . +class ReducerHook { + /// The first item of the pair returned by [React.userReducer]. + TState _state; + + /// The second item in the pair returned by [React.userReducer]. + void Function(TAction) _dispatch; + + ReducerHook(TState Function(TState state, TAction action) reducer, TState initialState) { + final result = React.useReducer(allowInterop(reducer), initialState); + _state = result[0]; + _dispatch = result[1]; + } + + /// Constructor for [useReducerLazy], calls lazy version of [React.useReducer] to + /// initialize [_state] to the return value of [init(initialArg)]. + /// + /// See: . + ReducerHook.lazy( + TState Function(TState state, TAction action) reducer, TInit initialArg, TState Function(TInit) init) { + final result = React.useReducer(allowInterop(reducer), initialArg, allowInterop(init)); + _state = result[0]; + _dispatch = result[1]; + } + + /// The current state map of the component. + /// + /// See: . + TState get state => _state; + + /// Dispatches [action] and triggers stage changes. + /// + /// > __Note:__ The dispatch function identity is stable and will not change on re-renders. + /// + /// See: . + void dispatch(TAction action) => _dispatch(action); +} + +/// Initializes state of a [DartFunctionComponent] to [initialState] and creates [dispatch] method. +/// +/// __Example__: +/// +/// ``` +/// Map reducer(Map state, Map action) { +/// switch (action['type']) { +/// case 'increment': +/// return {...state, 'count': state['count'] + 1}; +/// case 'decrement': +/// return {...state, 'count': state['count'] - 1}; +/// default: +/// return state; +/// } +/// } +/// +/// UseReducerTestComponent(Map props) { +/// final state = useReducer(reducer, {'count': 0}); +/// +/// return react.Fragment({}, [ +/// state.state['count'], +/// react.button({ +/// 'onClick': (_) => state.dispatch({'type': 'increment'}) +/// }, [ +/// '+' +/// ]), +/// react.button({ +/// 'onClick': (_) => state.dispatch({'type': 'decrement'}) +/// }, [ +/// '-' +/// ]), +/// ]); +/// } +/// ``` +/// +/// Learn more: . +ReducerHook useReducer( + TState Function(TState state, TAction action) reducer, TState initialState) => + ReducerHook(reducer, initialState); + +/// Initializes state of a [DartFunctionComponent] to [init(initialArg)] and creates [dispatch] method. +/// +/// __Example__: +/// +/// ``` +/// Map initializeCount(int initialValue) { +/// return {'count': initialValue}; +/// } +/// +/// Map reducer(Map state, Map action) { +/// switch (action['type']) { +/// case 'increment': +/// return {...state, 'count': state['count'] + 1}; +/// case 'decrement': +/// return {...state, 'count': state['count'] - 1}; +/// case 'reset': +/// return initializeCount(action['payload']); +/// default: +/// return state; +/// } +/// } +/// +/// UseReducerTestComponent(Map props) { +/// final ReducerHook state = useReducerLazy(reducer, props['initialCount'], initializeCount); +/// +/// return react.Fragment({}, [ +/// state.state['count'], +/// react.button({ +/// 'onClick': (_) => state.dispatch({'type': 'increment'}) +/// }, [ +/// '+' +/// ]), +/// react.button({ +/// 'onClick': (_) => state.dispatch({'type': 'decrement'}) +/// }, [ +/// '-' +/// ]), +/// react.button({ +/// 'onClick': (_) => state.dispatch({ +/// 'type': 'reset', +/// 'payload': props['initialCount'], +/// }) +/// }, [ +/// 'reset' +/// ]), +/// ]); +/// } +/// ``` +/// +/// Learn more: . +ReducerHook useReducerLazy( + TState Function(TState state, TAction action) reducer, TInit initialArg, TState Function(TInit) init) => + ReducerHook.lazy(reducer, initialArg, init); + /// Returns a memoized version of [callback] that only changes if one of the [dependencies] has changed. /// /// > __Note:__ there are two [rules for using Hooks](https://reactjs.org/docs/hooks-rules.html): diff --git a/lib/react_client/react_interop.dart b/lib/react_client/react_interop.dart index 196a07f9..eefcef35 100644 --- a/lib/react_client/react_interop.dart +++ b/lib/react_client/react_interop.dart @@ -48,6 +48,7 @@ abstract class React { external static List useState(dynamic value); external static void useEffect(dynamic Function() sideEffect, [List dependencies]); + external static List useReducer(Function reducer, dynamic initialState, [Function init]); external static Function useCallback(Function callback, List dependencies); external static ReactContext useContext(ReactContext context); } diff --git a/test/hooks_test.dart b/test/hooks_test.dart index 6328e7bf..91ae1e09 100644 --- a/test/hooks_test.dart +++ b/test/hooks_test.dart @@ -246,6 +246,186 @@ main() { }); }); + group('useReducer -', () { + ReactDartFunctionComponentFactoryProxy UseReducerTest; + DivElement textRef; + DivElement countRef; + ButtonElement addButtonRef; + ButtonElement subtractButtonRef; + ButtonElement textButtonRef; + + Map reducer(Map state, Map action) { + switch (action['type']) { + case 'increment': + return {...state, 'count': state['count'] + 1}; + case 'decrement': + return {...state, 'count': state['count'] - 1}; + case 'changeText': + return {...state, 'text': action['newText']}; + default: + return state; + } + } + + setUpAll(() { + var mountNode = new DivElement(); + + UseReducerTest = react.registerFunctionComponent((Map props) { + final state = useReducer(reducer, { + 'text': 'initialValue', + 'count': 0, + }); + + return react.div({}, [ + react.div({ + 'ref': (ref) { + textRef = ref; + }, + }, [ + state.state['text'] + ]), + react.div({ + 'ref': (ref) { + countRef = ref; + }, + }, [ + state.state['count'] + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'changeText', 'newText': 'newValue'}), + 'ref': (ref) { + textButtonRef = ref; + }, + }, [ + 'Set' + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'increment'}), + 'ref': (ref) { + addButtonRef = ref; + }, + }, [ + '+' + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'decrement'}), + 'ref': (ref) { + subtractButtonRef = ref; + }, + }, [ + '-' + ]), + ]); + }); + + react_dom.render(UseReducerTest({}), mountNode); + }); + + tearDownAll(() { + UseReducerTest = null; + }); + + test('initializes state correctly', () { + expect(countRef.text, '0'); + expect(textRef.text, 'initialValue'); + }); + + test('dispatch updates states correctly', () { + react_test_utils.Simulate.click(textButtonRef); + expect(textRef.text, 'newValue'); + + react_test_utils.Simulate.click(addButtonRef); + expect(countRef.text, '1'); + + react_test_utils.Simulate.click(subtractButtonRef); + expect(countRef.text, '0'); + }); + + group('useReducerLazy', () { + ButtonElement resetButtonRef; + + Map initializeCount(int initialValue) { + return {'count': initialValue}; + } + + Map reducer2(Map state, Map action) { + switch (action['type']) { + case 'increment': + return {...state, 'count': state['count'] + 1}; + case 'decrement': + return {...state, 'count': state['count'] - 1}; + case 'reset': + return initializeCount(action['payload']); + default: + return state; + } + } + + setUpAll(() { + var mountNode = new DivElement(); + + UseReducerTest = react.registerFunctionComponent((Map props) { + final ReducerHook state = useReducerLazy(reducer2, props['initialCount'], initializeCount); + + return react.div({}, [ + react.div({ + 'ref': (ref) { + countRef = ref; + }, + }, [ + state.state['count'] + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'reset', 'payload': props['initialCount']}), + 'ref': (ref) { + resetButtonRef = ref; + }, + }, [ + 'reset' + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'increment'}), + 'ref': (ref) { + addButtonRef = ref; + }, + }, [ + '+' + ]), + react.button({ + 'onClick': (_) => state.dispatch({'type': 'decrement'}), + 'ref': (ref) { + subtractButtonRef = ref; + }, + }, [ + '-' + ]), + ]); + }); + + react_dom.render(UseReducerTest({'initialCount': 10}), mountNode); + }); + + tearDownAll(() { + UseReducerTest = null; + }); + + test('initializes state correctly', () { + expect(countRef.text, '10'); + }); + + test('dispatch updates states correctly', () { + react_test_utils.Simulate.click(addButtonRef); + expect(countRef.text, '11'); + + react_test_utils.Simulate.click(resetButtonRef); + expect(countRef.text, '10'); + + react_test_utils.Simulate.click(subtractButtonRef); + expect(countRef.text, '9'); + }); + }); + }); + group('useCallback -', () { ReactDartFunctionComponentFactoryProxy UseCallbackTest; DivElement deltaRef;