redux.dart
redux.dart copied to clipboard
Best way to open a dialog
I'm wondering what is the best way to open a dialog?
My scenario:
In the middleware, I'm doing some async backend tasks. Depending on the result, I want to open a dialog to display an error message or to indicate that the user needs to log in again.
When the async task completes, I send another action to change the status, such as showErrorDialog = true
.
In the UI code, I listen for this status (using https://github.com/brianegan/reselect_dart) and when it becomes true
, I open the dialog and set this status to false
(so it will not be reopened).
It works, but it also looks wrong to me.
Is there a better way to open a dialog only when an action is triggered?
The way we do this kind of thing is through a Redux Epics EpicsClass
that listens for particular actions dispatched through the store. For example, this is how we show a Snackbar:
class SnackBarEpics extends EpicClass<AppState> {
SnackBarEpics({
required this.messengerKey,
});
final GlobalKey<ScaffoldMessengerState>? messengerKey;
@override
Stream call(Stream actions, EpicStore<AppState> store) {
await for (final action in actions) {
String? notificationKey;
if (action is SuccessCreateOne<MemoResponse>) {
notificationKey = 'notifications.createMemoSuccess';
}
if (notificationKey != null) {
messengerKey!.currentState?.removeCurrentSnackBar();
messengerKey!.currentState?.showSnackBar(SnackBar(
content: Text(translate(notificationKey)),
));
}
}
}
}
We pass in the GlobalKey for interacting with ScaffoldMessengerState at app startup.
I don't really like the idea of having the Store layer know anything about the UI layer, but at least here we have only one EpicClass that acts as a bridge between the store and the UI, it's reasonably clean.
I find this approach of listening for actions and doing something with Epics to be cleaner than having variables in the state to indicate whether to show a dialog. It's probably easier now with the Router
's declarative way of working, as that aligns better with Redux, but for things like this where you need to call a method to trigger something, Epics are working well for us.
Thanks, @MichaelMarner for your example, but I don't like having any UI stuff in the store either :(
I thought about it a bit more and came to the conclusion that a callback added to an action would be nice. Here is an example:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:redux/redux.dart';
part 'test_redux_callback.freezed.dart';
void main(List<String> arguments) {
final store = Store<AppState>(
reducer,
initialState: AppState.initialState(),
middleware: [middleware],
distinct: true,
);
store.onChange.listen(print);
store
..dispatch(CounterAction(count: 1))
..dispatch(CounterAction(count: 1))
..dispatch(CounterAction(count: 2, callback: callback))
..dispatch(CounterAction(count: 3, callback: callback))
..dispatch(CounterAction(count: 3, callback: callback))
..dispatch(CounterAction(count: 5, callback: callback));
}
void callback(int count) => print('count: $count');
typedef Callback = void Function(int count);
@freezed
class CounterAction with _$CounterAction implements MiddlewareAction, ReducerAction {
CounterAction._();
factory CounterAction({
required int count,
Callback? callback,
}) = _CounterAction;
@override
void middleware(Store<AppState> store, Next next) {
this.callback?.call(count);
next();
}
@override
AppState reduce(AppState state) {
return state.copyWith(counter: count);
}
}
@freezed
class AppState with _$AppState {
AppState._();
factory AppState({
required int counter,
}) = _AppState;
factory AppState.initialState() => AppState(
counter: 0,
);
}
abstract class MiddlewareAction {
void middleware(Store<AppState> store, Next next);
}
typedef Next = void Function();
void middleware(Store<AppState> store, action, NextDispatcher nextDispatcher) {
void next() {
nextDispatcher(action);
}
if (action is MiddlewareAction) {
action.middleware(store, next);
} else {
nextDispatcher(action);
}
}
abstract class ReducerAction {
AppState reduce(AppState state);
}
AppState reducer(AppState state, action) {
if (action is ReducerAction) {
return action.reduce(state);
}
return state;
}
We do something similar that, we think, separates concerns.
typedef ActionResult = void Function(AppAction action);
@noCopyFreezed
class Logout with _$Logout implements AppAction {
const factory Logout({
required ActionResult result,
}) = LogoutStart;
const factory Logout.successful() = LogoutSuccessful;
@Implements<ErrorAction>()
const factory Logout.error(Object error, StackTrace stackTrace) = LogoutError;
}
In the epics we have something like
@singleton
class AuthEpics implements EpicClass<AppState> {
const AuthEpics({required AuthApi api}) : _api = api;
final AuthApi _api;
@override
Stream<dynamic> call(Stream<dynamic> actions, EpicStore<AppState> store) {
return combineEpics<AppState>(<Epic<AppState>>[
TypedEpic<AppState, LogoutStart>(_logoutStart).call,
])(actions, store);
}
Stream<AppAction> _logoutStart(Stream<LogoutStart> actions, EpicStore<AppState> store) {
return actions.flatMap((LogoutStart action) {
return Stream<void>.value(null)
.asyncMap((_) => _api.logOut())
.map((_) => const Logout.successful())
.onErrorReturnWith((Object error, StackTrace stackTrace) => Logout.error(error, stackTrace))
.doOnData(action.result);
});
}
}
In then in UI we have something like:
class LogoutButton extends StatelessWidget {
const LogoutButton({super.key});
@override
Widget build(BuildContext context) {
return ListTile(
title: const Text('Logout'),
onTap: () {
final Store<AppState> store = StoreProvider.of<AppState>(context);
final NavigatorState navigator = Navigator.of(context);
store.dispatch(
Logout(
result: (AppAction action) {
if (action is LogoutError) {
final Object error = action.error;
final CapturedThemes themes = InheritedTheme.capture(
from: context,
to: Navigator.of(
context,
rootNavigator: true,
).context,
);
navigator.push(
DialogRoute<void>(
context: context,
themes: themes,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Error'),
content: Text('$error'),
);
},
),
);
}
},
),
);
},
);
}
}