marcglasberg / async_redux

Flutter Package: A Redux version tailored for Flutter, which is easy to learn, to use, to test, and has no boilerplate. Allows for both sync and async reducers.
Other
230 stars 41 forks source link

Provide official useSelector and useDispatch hooks (for flutter_hooks) #67

Closed bugeats closed 6 months ago

bugeats commented 4 years ago

I'm a Flutter noob trying to get the nice state management stack that I'm accustomed to from react-redux. The combination of useSelector and useDispatch hooks is the cleanest and most concise state management strategy I've yet encountered, especially combined with reselect.

The flutter_hooks package seems to provide similar react hooks functionality, but it appears that the dirty-checking model is totally different. For example useState returns a ValueNotifier wrapped value instead of just the actual value. I'm afraid it's currently beyond my Flutter experience to understand why that is and how to use it to get what I want.

So far I have this:

// seems to work ok, probably not done correctly
useDispatch() {
  final BuildContext context = useContext();

  final redux.Store<AppState> store =
      redux.StoreProvider.of<AppState>(context, false);

  return store.dispatch;
}

And this:

// Does NOT re-render the widget when store state changes
useSelector(Function selectorFn) {
  final BuildContext context = useContext();
  final AppState state = redux.StoreProvider.state<AppState>(context);

  return selectorFn(state);
}

It seems that @marcglasberg and @rrousselGit have collaborated successfully in the past. Maybe you guys can help me see this dream accomplished? Let me know if I should move this ticket to a different repo.

bugeats commented 4 years ago

In my opinion, React Hooks are the best thing that's ever happened to React and reactive GUIs since functional components. It may be worth the time to take a look at what flutter_hooks is trying to do.

So far I'm getting the impression that Flutter/Dart is so heavily object-oriented that these sorts of simple functional approaches tend not to arise naturally.

w3ggy commented 6 months ago

@marcglasberg hi! Thank you for your work!

We used to use async_redux package with flutter_hooks

For example:

Dispatcher useDispatcher() {
  final context = useContext();
  final storeProvider = StoreProvider.of<AppState>(context, 'dispatcher');

  return storeProvider.dispatchAsync;
}
T useGlobalState<T>(
  T Function(AppState s) converter, {
  bool distinct = true,
}) {
  final context = useContext();
  final store = StoreProvider.of<AppState>(context, 'useGlobalState hook');

  return use(_GlobalStateHook(
    store: store,
    converter: converter,
    distinct: distinct,
  ));
}

As you see, it has been working via store/direct calls of the dispatch methods. In the 22.0.0 version the method StoreProvider.of was removed. Is there any way to make async_redux work with flutter_hooks now?

Now I receive the error Cannot listen to inherited widgets inside HookState.initState. Use HookState.build instead when I try to use useGlobalState/useDispatcher which is ok, but as I understand I cannot dispatch/check state without inherited widget anymore?

marcglasberg commented 6 months ago

@w3ggy

Note dispatchAsync was renamed to dispatchAndWait, and StoreProvider now has a dispatchAndWait static method.

So I think instead of:

StoreProvider.of<AppState>(context, 'dispatcher').dispatchAsync

You can use:

StoreProvider.dispatchAndWait<AppState>;
marcglasberg commented 6 months ago

@w3ggy

Now, the reason I removed access to the store through StoreProvider is that accessing StoreProvider.state<St> will rebuild the widget, while accessing StoreProvider.store<St>.state will not. To compensate for the removal of StoreProvider.store<St> I've created direct access to all methods:

extension BuildContextExtensionForProviderAndConnector<St> on BuildContext {

  /// Get the state, without a StoreConnector.
  /// Note: Widgets that use this method will rebuild whenever the state changes.
  /// It's recommended that you define this extension in your own code:
  /// `extension BuildContextExtension on BuildContext { AppState get state => getState<AppState>(); }`
  /// This will allow you to write: `var state = context.state;`
  St getState<St>() => StoreProvider.state<St>(this);

  FutureOr<ActionStatus> dispatch(ReduxAction action, {bool notify = true}) => StoreProvider.dispatch(this, action, notify: notify);

  Future<ActionStatus> dispatchAndWait(ReduxAction action, {bool notify = true}) => StoreProvider.dispatchAndWait(this, action, notify: notify);

  ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => StoreProvider.dispatchSync(this, action, notify: notify);

  /// ```
  /// dispatch(MyAction());
  /// if (context.isWaiting(MyAction)) { // Show a spinner }
  /// ```
  bool isWaiting(Object actionOrTypeOrList) => StoreProvider.isWaiting(this, actionOrTypeOrList);

  /// ```
  /// if (context.isFailed(MyAction)) { // Show an error message. }
  /// ```
  bool isFailed(Object actionOrTypeOrList) => StoreProvider.isFailed(this, actionOrTypeOrList);

  /// ```
  /// if (context.isFailed(SaveUserAction)) Text(context.exceptionFor(SaveUserAction)!.reason ?? '');
  /// ```
  UserException? exceptionFor(Object actionOrTypeOrList) => StoreProvider.exceptionFor(this, actionOrTypeOrList);

  void clearExceptionFor(Object actionOrTypeOrList) => StoreProvider.clearExceptionFor(this, actionOrTypeOrList);
}

@w3ggy Could you please show me the complete code for your _GlobalStateHook?

w3ggy commented 6 months ago

@w3ggy

Note dispatchAsync was renamed to dispatchAndWait, and StoreProvider now has a dispatchAndWait static method.

So I think instead of:

StoreProvider.of<AppState>(context, 'dispatcher').dispatchAsync

You can use:

StoreProvider.dispatchAndWait<AppState>;

Yeah, this one works fine now. Thank you.

Dispatcher useDispatcher() {
  final context = useContext();

  return context.dispatchAndWait;
}
w3ggy commented 6 months ago
> @w3ggy > > Now, the reason I removed access to the `store` through `StoreProvider` is that accessing `StoreProvider.state` will rebuild the widget, while accessing `StoreProvider.store.state` will not. To compensate for the removal of `StoreProvider.store` I've created direct access to all methods: > > ```dart > extension BuildContextExtensionForProviderAndConnector on BuildContext { > > /// Get the state, without a StoreConnector. > /// Note: Widgets that use this method will rebuild whenever the state changes. > /// It's recommended that you define this extension in your own code: > /// `extension BuildContextExtension on BuildContext { AppState get state => getState(); }` > /// This will allow you to write: `var state = context.state;` > St getState() => StoreProvider.state(this); > > FutureOr dispatch(ReduxAction action, {bool notify = true}) => StoreProvider.dispatch(this, action, notify: notify); > > Future dispatchAndWait(ReduxAction action, {bool notify = true}) => StoreProvider.dispatchAndWait(this, action, notify: notify); > > ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => StoreProvider.dispatchSync(this, action, notify: notify); > > /// ``` > /// dispatch(MyAction()); > /// if (context.isWaiting(MyAction)) { // Show a spinner } > /// ``` > bool isWaiting(Object actionOrTypeOrList) => StoreProvider.isWaiting(this, actionOrTypeOrList); > > /// ``` > /// if (context.isFailed(MyAction)) { // Show an error message. } > /// ``` > bool isFailed(Object actionOrTypeOrList) => StoreProvider.isFailed(this, actionOrTypeOrList); > > /// ``` > /// if (context.isFailed(SaveUserAction)) Text(context.exceptionFor(SaveUserAction)!.reason ?? ''); > /// ``` > UserException? exceptionFor(Object actionOrTypeOrList) => StoreProvider.exceptionFor(this, actionOrTypeOrList); > > void clearExceptionFor(Object actionOrTypeOrList) => StoreProvider.clearExceptionFor(this, actionOrTypeOrList); > } > ``` > > @w3ggy Could you please show me the complete code for your `_GlobalStateHook`?

_GlobalStateHook:

```dart class _GlobalStateHook extends Hook { const _GlobalStateHook({ required this.store, required this.converter, this.distinct = true, }); final T Function(S) converter; final bool distinct; final Store store; @override HookState> createState() { return _GlobalStateStateHook(); } } class _GlobalStateStateHook extends HookState> { StreamSubscription? _storeSubscription; late T _state; bool get isInitialised => _storeSubscription != null; @override void initHook() { super.initHook(); _updateState(hook.store.state); final onStoreChanged = hook.store.onChange; _storeSubscription = onStoreChanged.listen(_updateState); } @override T build(BuildContext context) { return _state; } @override void dispose() { _storeSubscription?.cancel(); super.dispose(); } void _updateState(S globalState) { final state = hook.converter(globalState); if (isInitialised && hook.distinct && state == _state) { return; } setState(() => _state = state); } } ```

Now, the reason I removed access to the store through StoreProvider is that accessing StoreProvider.state will rebuild the widget, while accessing StoreProvider.store.state will not. To compensate for the removal of StoreProvider.store I've created direct access to all methods:

I got it. I am just wondering if there is any way to get access directly. It looks like after the 22.0 version there is no sense to use flutter_hooks for state lookup anymore. But I already have legacy code 😅 and I am trying to understand how to update async_redux without breaking changes for the old code base. In other words, I need the way to access to the state in the build method (because flutter_hooks builds hooks inside the build method). I tried to use something like this:

T useGlobalState<T>(
  T Function(AppState s) converter, {
  bool distinct = true,
}) {
  final context = useContext();
  final state = StoreProvider.state<AppState>(context);

  return converter(state);
}

but it throws Cannot listen to inherited widgets inside HookState.initState. Use HookState.build instead

Thanks in advance!

marcglasberg commented 6 months ago

Yes, I think you'd achieve the same result now by just writing context.state, context.dispatch, etc. But don't worry, I'll solve this for you.

Just one more question: Is GlobalState an internal class from flutter_hooks? If not could you please provide its code too? I need all code that's not provided by flutter_hooks.

w3ggy commented 6 months ago

Yes, I think you'd achieve the same result now by just writing context.state, context.dispatch, etc. But don't worry, I'll solve this for you.

Just one more question: Is GlobalState an internal class from flutter_hooks? If not could you please provide its code too? I need all code that's not provided by flutter_hooks.

Oh, sorry.

The GlobalState is just an interface for the AppState. You can replace GlobalState with AppState for local testing.

This one should work

```dart class _GlobalStateHook extends Hook { const _GlobalStateHook({ required this.store, required this.converter, this.distinct = true, }); final T Function(S) converter; final bool distinct; final Store store; @override HookState> createState() { return _GlobalStateStateHook(); } } class _GlobalStateStateHook extends HookState> { StreamSubscription? _storeSubscription; late T _state; bool get isInitialised => _storeSubscription != null; @override void initHook() { super.initHook(); _updateState(hook.store.state); final onStoreChanged = hook.store.onChange; _storeSubscription = onStoreChanged.listen(_updateState); } @override T build(BuildContext context) { return _state; } @override void dispose() { _storeSubscription?.cancel(); super.dispose(); } void _updateState(S globalState) { final state = hook.converter(globalState); if (isInitialised && hook.distinct && state == _state) { return; } setState(() => _state = state); } } T useGlobalState( T Function(AppState s) converter, { bool distinct = true, }) { final context = useContext(); final store = StoreProvider.of(context, 'useGlobalState hook'); return use(_GlobalStateHook( store: store, converter: converter, distinct: distinct, )); } ```
marcglasberg commented 6 months ago

@w3ggy @bugeats

I just published package https://pub.dev/packages/flutter_hooks_async_redux

You have to add the following dependencies to your pubspec.yaml:

dependencies:
  flutter_hooks: ^0.20.5 # or newer
  async_redux: ^22.4.0 # or newer
  flutter_hooks_async_redux: ^1.0.3 # or newer 

And then, all features of Flutter Hooks and Async Redux are available for you.

useSelector

useSelector lets you select a part of the state and subscribe to updates.

You should give it a function that gets the sate and returns only the part of the state that the widget needs.

Example:

String username 
   = useSelector<AppState, String>((state) => state.username);

Note: If your state is called AppState, you can define your own useAppState hook, like this:

T useAppState<T>(T Function(AppState state) converter, {bool distinct = true}) =>
    useSelector<T, AppState>(converter, distinct: distinct);

This will simplify the use of the hook, like this:

String username = useAppState((state) => state.username);
marcglasberg commented 6 months ago

@w3ggy

In your case, you should remove all your code and add this one:

T useGlobalState<T>(T Function(GlobalState state) converter, {bool distinct = true}) =>
    useSelector<T, GlobalState>(converter, distinct: distinct);

Please replace your useDispatcher to useDispatchAndWait in your code. That's better, because there are other ways to dispatch, like useDispatch and useDiaspatchSync.

Important: Please do use this new package instead of fixing your old code, because then new Async Redux versions will continue to work in the future without you having to do any manual changes.

Also, could you please tell me if everything is working fine now? Thank you!

w3ggy commented 6 months ago
> @w3ggy > > In your case, you should remove all your code and add this one: > > ```dart > T useGlobalState(T Function(GlobalState state) converter, {bool distinct = true}) => > useSelector(converter, distinct: distinct); > ``` > > Please replace your `useDispatcher` to `useDispatchAndWait` in your code. That's better, because there are other ways to dispatch, like `useDispatch` and `useDiaspatchSync`. > > **Important:** Please do use this new package instead of fixing your old code, because then new Async Redux versions will continue to work in the future without you having to do any manual changes. > > Also, could you please tell me if everything is working fine now? Thank you!

Thank you!

I added the flutter_hooks_async_redux package and the useGlobalState function to my project. But when I tried to use it, it throws: Cannot listen to inherited widgets inside HookState.initState. Use HookState.build instead.

I prepared the small example. It doesn't work on my end when I call the dispatch method inside useEffect which is called during the build where we cannot use inherited widgets because under the hood useEffect uses initState inside the element where Inherited widgets are not accessible by design. 🤔

```dart import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart' hide Action; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks_async_redux/flutter_hooks_async_redux.dart'; class AppState { final String username; const AppState({this.username = ''}); } T useAppState(T Function(AppState state) converter, {bool distinct = true}) => useSelector(converter, distinct: distinct); void main() { runApp( MaterialApp( home: StoreProvider( store: Store(initialState: const AppState(username: 'Test')), child: const Application(), ), ), ); } class Application extends HookWidget { const Application({super.key}); @override Widget build(BuildContext context) { final dispatch = useDispatch(); final state = useAppState((state) => state.username); useEffect(() { dispatch(SetUsernameAction('John')); }, []); return Scaffold( body: Center( child: Text('State value is $state'), ), floatingActionButton: FloatingActionButton( child: const Icon(Icons.clear), onPressed: () { dispatch(ResetAppStateAction()); }, ), ); } } class SetUsernameAction extends ReduxAction { SetUsernameAction(this.username); final String username; @override AppState reduce() { return AppState(username: username); } } class ResetAppStateAction extends ReduxAction { @override AppState reduce() { return const AppState(); } } ```
marcglasberg commented 6 months ago

@w3ggy Could you please try flutter_hooks_async_redux: ^1.0.3 and let me know?

https://github.com/marcglasberg/flutter_hooks_async_redux

w3ggy commented 6 months ago

@w3ggy Could you please try flutter_hooks_async_redux: ^1.0.3 and let me know?

https://github.com/marcglasberg/flutter_hooks_async_redux

Dispatcher useDispatcher() {
  final context = useContext();

  return context.dispatchAndWait;
}

This one still doesn't work inside useEffect with the same error Cannot listen to inherited widgets inside HookState.initState. Use HookState.build instead, but original useDispatch does.

typedef Dispatcher = Future<void> Function(BaseAction);

Dispatcher useDispatcher() {
  return useDispatchAndWait();
}

This one seems to be working well on initial testing. I will send this build on QA later and let you know if we found any other issues.

Thank you for your help! 😄

marcglasberg commented 6 months ago

Yes, context.dispatchAndWait is not supposed to work inside useEffect. You should use the provided useDispatchAndWait(). Glad it works. Let me know if you find any other problems. Thanks!