better_effect_flutter 0.4.0
better_effect_flutter: ^0.4.0 copied to clipboard
Flutter MVVM integration for better_effect with typed EffectCommand state, scoped Runtime bootstrap, and one-shot UI listeners.
better_effect_flutter #
Typed Effects behind Flutter MVVM Commands.
better_effect_flutter connects the Dart-only
better_effect runtime to Flutter
widgets and ViewModels. It turns a typed Effect<Output, Failure>
into an observable
EffectCommandState<Output, Failure>, while keeping
dependency resolution, expected failures, and unexpected defects separate.
The package is intentionally focused:
better_effectdescribes and runs lazy, dependency-aware Effects;EffectCommandexecutes one Effect through a long-lived Runtime;EffectViewModelowns Commands and presentation state;EffectCommandBuilderrenders state andEffectCommandListenerhandles one-shot UI effects;BetterEffectBootstrapandBetterEffectProviderput the Runtime in the widget tree.
It does not replace Provider, Riverpod, BLoC, Signals, MobX, or another state-management package. Those packages can still create and expose ViewModels in their usual way.
When it fits #
Use this package when a Flutter feature needs:
- typed domain failures instead of a single untyped error flag;
- a separate representation for defects such as missing dependencies or programming errors;
- loading, success, failure, defect, and interruption states from one sealed hierarchy;
- explicit handling of repeated taps, searches, refreshes, uploads, or ordered writes;
- one-shot SnackBars, dialogs, navigation, and analytics without manually clearing result flags;
- automatic disposal of Commands with a ViewModel;
- a long-lived
better_effectRuntime shared by a Flutter subtree.
If an application only needs a simple FutureBuilder, or already has
a command abstraction with these semantics, this package may add more structure
than necessary.
The flow #
Flutter app
│
▼
BetterEffectBootstrap / BetterEffectProvider
│ creates and owns
▼
Runtime + EffectCommands
│ supplied to
▼
EffectViewModel
│ owns
▼
EffectCommand<Input, Output, Failure>
│ executes
▼
Effect<Output, Failure>
│ resolves services with use<T>()
▼
Module + Runtime
An EffectCommand is a
ValueListenable<EffectCommandState<A, E>>. The View can
observe the same execution declaratively while imperative code awaits
execute() and receives an Exit<A, E>.
Requirements #
- Dart 3.10 or newer;
- Flutter 3.38 or newer;
better_effect0.4.x.
The package re-exports better_effect, so most applications need
only one import:
import 'package:better_effect_flutter/better_effect_flutter.dart';
Installation #
flutter pub add better_effect_flutter
Or add it manually:
dependencies:
better_effect_flutter: ^0.4.0
During local monorepo development, this repository uses an ignored
pubspec_overrides.yaml to point better_effect at the
checked-out sibling package. Applications outside this repository should use
the hosted dependency shown above.
Quick start #
The recommended feature shape is ordinary Dart contracts, Effects for application operations, a Module at the composition root, a ViewModel that owns Commands, and a View that renders or listens to Command state.
1. Define a typed failure and a contract #
sealed class AppFailure implements Exception {
const AppFailure();
}
final class GreetingFailure extends AppFailure {
const GreetingFailure(this.message);
final String message;
@override
String toString() => 'GreetingFailure($message)';
}
typedef AppEffect<A extends Object> = Effect<A, AppFailure>;
abstract interface class GreetingService {
AppEffect<String> greet(String name);
}
2. Resolve dependencies inside an Effect #
final class GreetingServiceLive implements GreetingService {
@override
AppEffect<String> greet(String name) => Effect.tryAsync(
() async => 'Hello, $name!',
onError: (error, stackTrace) =>
GreetingFailure(error.toString()),
);
}
AppEffect<String> greeting(String name) => Effect.result((use) async {
final service = use<GreetingService>();
return use.unwrap(service.greet(name));
});
use<GreetingService>() resolves from the Runtime executing this
Effect. It is not a global service locator. Constructor injection from
better_effect remains available; both styles can coexist.
3. Configure the Module #
final appModule = Module([
.provide<GreetingService>(GreetingServiceLive.new),
]);
4. Start the Runtime at the Flutter boundary #
When this package owns the application root,
runBetterEffectApp is the shortest bootstrap:
Future<void> main() {
return runBetterEffectApp(
module: appModule,
app: const GreetingApp(),
);
}
It starts one long-lived Runtime, inserts a
BetterEffectScope, and closes the Runtime when the provider is
disposed or the Flutter view detaches.
For add-to-app, previews, tests, or a feature root, use the declarative widget:
runApp(
BetterEffectBootstrap(
module: appModule,
loadingBuilder: (_) => const SplashScreen(),
errorBuilder: (context, error, stackTrace, retry) {
return StartupErrorScreen(
error: error,
onRetry: retry,
);
},
builder: (_) => const GreetingApp(),
),
);
5. Create a ViewModel and its Command #
final class GreetingViewModel extends EffectViewModel {
GreetingViewModel(super.commands) {
greet = commandWithInput<String, String, AppFailure>(
greeting,
keepPreviousData: false,
debugLabel: 'GreetingViewModel.greet',
);
}
late final EffectCommand<String, String, AppFailure> greet;
}
The ViewModel receives EffectCommands, not the Module, Runtime,
injector, or every service. Commands created through
EffectViewModel are automatically disposed with the ViewModel.
6. Create the ViewModel at a composition boundary #
EffectViewModelBuilder is the package's state-management-free
option:
final class GreetingScreen extends StatelessWidget {
const GreetingScreen({super.key});
@override
Widget build(BuildContext context) {
return EffectViewModelBuilder<GreetingViewModel>(
create: (_, commands) => GreetingViewModel(commands),
builder: (_, viewModel, _) => GreetingView(viewModel: viewModel),
);
}
}
If the app already uses Provider, Riverpod, BLoC, or another adapter, obtain the scoped factory without listening:
final viewModel = GreetingViewModel(context.effectCommands);
context.effectCommands is intended for factories.
watchEffectCommands() is the listening form used by lifecycle
adapters such as EffectViewModelBuilder.
Commands #
Commands with and without input #
Use EffectCommand0 for an operation without input:
late final EffectCommand0<HomeData, AppFailure> load;
load = command<HomeData, AppFailure>(_load);
await load.execute();
Use EffectCommand<I, A, E> when an operation has one input:
late final EffectCommand<UserId, Unit, AppFailure> deleteUser;
deleteUser = commandWithInput<UserId, Unit, AppFailure>(_deleteUser);
await deleteUser.execute(userId);
For several logical values, use a named record rather than arity-specific Command2/Command3 types:
typedef LoginInput = ({String email, String password});
late final EffectCommand<LoginInput, Session, AuthFailure> login;
await login.execute((
email: email,
password: password,
));
Commands are callable objects, so
await login(input) is equivalent to
await login.execute(input).
execute() returns Future<Exit<A, E>>. The View
observes state while imperative callers can inspect
ExitSuccess, ExitFailure, ExitDefect, or
ExitInterrupted directly.
Create Commands directly #
ViewModels normally receive EffectCommands, which binds every
Command to the same long-lived Runtime:
final commands = EffectCommands(runtime);
final load = commands.fromEffect(
loadHome(),
debugLabel: 'Home.load',
);
final search = commands.withInput<Query, List<Result>, SearchFailure>(
(query) => searchEffect(query),
concurrency: EffectCommandConcurrency.latest,
);
Use EffectViewModel.command and
commandWithInput when the ViewModel can extend
EffectViewModel. Use EffectCommands.call,
fromEffect, or withInput when a framework adapter or
an existing base class creates Commands.
Command state #
Every Command exposes this sealed state hierarchy:
| State | Meaning |
|---|---|
EffectCommandIdle |
No authoritative execution is running, or the Command was reset. |
EffectCommandRunning |
An Effect is currently executing. |
EffectCommandSuccess |
The Effect returned a successful value. |
EffectCommandFailure |
The Effect returned an expected, typed failure. |
EffectCommandDefect |
An unexpected exception, Error, missing service, or cleanup failure occurred. |
EffectCommandInterrupted |
The Command stopped owning the active execution result. |
The distinction keeps an expected domain failure separate from a defect:
Widget render(EffectCommandState<Session, AuthFailure> state) {
return switch (state) {
EffectCommandIdle() => const Text('Ready'),
EffectCommandRunning(:final previous) =>
previous == null
? const CircularProgressIndicator()
: SessionView(session: previous),
EffectCommandSuccess(:final value) => SessionView(session: value),
EffectCommandFailure(:final error, :final previous) =>
LoginError(error: error, previous: previous),
EffectCommandDefect(:final defect) => UnexpectedError(error: defect),
EffectCommandInterrupted(:final previous) =>
InterruptedView(previous: previous),
};
}
Useful getters include value, isRunning,
data, error, lastSuccess,
lastFailure, lastExit, lastDefect,
resultOrNull, pendingCount, and
queuedCount.
By default, Commands keep their latest successful value while running or after
a failure. Set keepPreviousData: false when the View must clear
data during those states. The retained value is available through
state.dataOrNull or state.previousOrNull.
## Select state without rebuilding the whole subtree
`EffectCommandSelector` rebuilds only when its strongly typed selected
value changes. Default equality uses Dart `==`:
```dart
EffectCommandSelector<User, AppFailure, bool>(
command: viewModel.loadUser,
selector: (state) => state.isRunning,
child: const UserContent(),
builder: (context, isRunning, child) {
return LoadingOverlay(visible: isRunning, child: child!);
},
)
```
Collections can supply custom equality:
```dart
equals: (previous, current) => listEquals(previous, current),
```
Select queue and policy counts from the Command snapshot without
changing the durable state revision:
```dart
EffectCommandSelector.snapshot(
command: viewModel.save,
selector: (snapshot) => snapshot.queuedCount,
builder: (context, queued, child) => QueueBadge(count: queued),
)
```
Existing builders can filter transitions directly:
```dart
EffectCommandBuilder<User, AppFailure>(
command: viewModel.loadUser,
buildWhen: (previous, current) =>
previous.dataOrNull != current.dataOrNull,
builder: ...,
)
```
Selectors never consume listener revisions. Keep navigation,
SnackBars, dialogs, and analytics in `EffectCommandListener`.
See [`doc/command_selectors.md`](doc/command_selectors.md) for
equality, replacement, snapshot, and testing guidance.
## Render state and one-shot effects
EffectCommandBuilder #
Use the builder for state-driven rendering. It is a typed wrapper around
Flutter's ValueListenableBuilder:
EffectCommandBuilder<String, AppFailure>(
command: viewModel.greet,
builder: (context, state, child) {
if (state.isRunning && state.dataOrNull == null) {
return const Center(child: CircularProgressIndicator());
}
if (state case EffectCommandFailure(:final error)) {
return ErrorView(message: error.toString());
}
if (state case EffectCommandDefect(:final defect)) {
return ErrorView(message: 'Unexpected error: $defect');
}
return Text(state.dataOrNull ?? 'Press the button');
},
)
EffectCommandListener #
Use the listener for navigation, dialogs, SnackBars, analytics, or other one-shot presentation effects:
EffectCommandListener<String, AppFailure>(
command: viewModel.greet,
onSuccess: (context, message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
},
onFailure: (context, failure, previous) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(failure.toString())),
);
},
onDefect: (context, defect, stackTrace, previous) {
FlutterError.reportError(
FlutterErrorDetails(exception: defect, stack: stackTrace),
);
},
child: GreetingButton(viewModel: viewModel),
)
Each visible state has a monotonically increasing revision. A
listener consumes each revision once, so a SnackBar or navigation callback does
not repeat after an unrelated rebuild. Set fireImmediately: true
when the state present at mount should also be delivered. Use
listenWhen to filter transitions.
EffectCommandConsumer #
EffectCommandConsumer combines Builder and Listener for one subtree:
EffectCommandConsumer<String, AppFailure>(
command: viewModel.greet,
onSuccess: (context, message) {
Navigator.of(context).pushNamed('/greeting');
},
onFailure: (context, failure, previous) {
showGreetingError(context, failure);
},
builder: (context, state, child) {
return GreetingButton(
busy: state.isRunning,
onPressed: viewModel.greet,
);
},
)
Repeated executions #
EffectCommandConcurrency defines what happens when
execute is called while work is already in flight.
drop #
save = command(_save, concurrency: EffectCommandConcurrency.drop);
This is the default. A repeated call returns the active Future and does not start duplicate work. It is appropriate for submit buttons, refresh actions, and destructive operations.
latest #
search = commandWithInput(
_search,
concurrency: EffectCommandConcurrency.latest,
);
Every call starts. Only the newest execution may update visible Command state;
older completions cannot overwrite it. Older callers still receive their own
Exit. This is useful for search fields and changing filters.
queue #
toggle = commandWithInput(
_toggle,
concurrency: EffectCommandConcurrency.queue,
);
Calls are executed serially in request order. Use it for ordered writes, uploads, or toggles where every user intent matters.
Extensible Command policies #
New code can coordinate execution and input timing with one
immutable CommandPolicy:
search = commandWithInput(
searchUsers,
policy: const CommandPolicy.latest(
cancelPrevious: true,
trigger: TriggerPolicy.debounce(
Duration(milliseconds: 300),
),
),
);
The existing EffectCommandConcurrency.drop, latest, and
queue arguments remain source-compatible and map exactly to
CommandPolicy.drop(), latest(), and queue().
Bounded queues define caller outcomes explicitly:
save = commandWithInput(
saveDraft,
policy: const CommandPolicy.queue(
maxPending: 10,
overflow: QueueOverflow.dropOldest,
),
);
Debounce and throttle are available only for Commands with input
and use an explicitly installed EffectClock. Replaced,
suppressed, or overflowed callers receive ExitInterrupted;
typed domain failures remain ExitFailure<E>.
See doc/command_policies.md for
leading/trailing timing, queue overflow, retry-input rules,
cancellation, observability, and deterministic tests.
Cancellation, retry, and reset #
Dart Futures are not generally cancellable. command.cancel() publishes
interruption immediately and requests cooperative cancellation through the core
EffectExecution. Physical work remains owned by the Runtime until it finishes:
command.cancel();
Cancellation:
- publishes
EffectCommandInterrupted; - completes the caller's Future with
ExitInterrupted; - ignores the eventual stale completion;
- interrupts queued executions by default.
Inside an Effect, observe use.cancellation or call
use.cancellation.throwIfCancelled() at a cooperative boundary. Provide
onCancel only as an adapter for APIs with their own cancellation protocol,
such as a Dio token, download manager, or isolate:
upload = commandWithInput(
_upload,
onCancel: cancelToken.cancel,
);
Use cancel(clearQueued: false) to interrupt the active execution and
start the next queued operation.
Commands without input can always retry. Input Commands remember the latest accepted input:
await command.retry();
With drop, an input rejected while another execution is active does
not replace the input used by retry().
reset() returns to EffectCommandIdle and may retain the
latest successful data. clear() also removes cached success and
failure values. Both return false while authoritative or queued
work exists.
Bootstrap and Runtime ownership #
runBetterEffectApp #
Use this when the package owns the application root:
Future<void> main() {
return runBetterEffectApp(
module: appModule,
observer: (transition) => debugPrint('$transition'),
startupErrorBuilder: (error, stackTrace) {
return StartupErrorApp(error: error, stackTrace: stackTrace);
},
app: const App(),
);
}
The function starts the Module before calling runApp. If startup
fails, startupErrorBuilder can render a fallback root; without it
the original error is rethrown. backend accepts an already-created
ResolverBackend. Use onRuntimeCloseError to handle
cleanup errors.
BetterEffectBootstrap #
Use this widget for add-to-app, previews, tests, or a subtree that needs asynchronous startup:
BetterEffectBootstrap(
module: appModule,
loadingBuilder: (_) => const SplashScreen(),
minimumLoadingDuration: const Duration(milliseconds: 250),
errorBuilder: (context, error, stackTrace, retry) {
return StartupError(
error: error,
onRetry: retry,
);
},
builder: (_) => const FeatureRoot(),
)
The widget shows loading until the Runtime starts, exposes a retry callback
after startup failure, and closes the Runtime when it is disposed.
backendFactory creates a fresh resolver for every start/retry.
Change restartKey to intentionally restart an otherwise identical
Module. Use lifecyclePolicy to configure widget disposal, application
exit, cooperative interruption, and the shutdown grace period:
BetterEffectLifecyclePolicy.application(
gracePeriod: const Duration(seconds: 2),
interruptExecutionsBeforeClose: true,
)
The former closeRuntimeOnDetach parameter remains temporarily available as
a deprecated migration path.
BetterEffectProvider #
Use the provider when a Runtime already exists:
BetterEffectProvider(
runtime: runtime,
child: const FeatureRoot(),
)
The default constructor creates an application-owned boundary. Set
ownership: BetterEffectRuntimeOwnership.widget for a feature subtree, and
use .value when another boundary controls the Runtime lifecycle:
BetterEffectProvider.value(
runtime: externallyOwnedRuntime,
child: const FeatureRoot(),
)
Do not use the owning constructor for a Runtime that is also closed elsewhere.
Flutter composition helpers #
The nearest scope exposes these BuildContext extensions:
final commands = context.effectCommands; // non-listening
final commands = context.watchEffectCommands(); // listens for scope replacement
final runtime = context.effectRuntime;
final result = await context.runEffect(program);
final exit = await context.runEffectExit(program);
final service = context.readEffectService<ApiClient>();
Use effectCommands in ViewModel factories. Use
watchEffectCommands() in adapters that must recreate their object if
the Runtime scope changes. runEffect and
runEffectExit are useful at integration boundaries.
readEffectService is for route/provider factories and adapters;
business code should resolve services through use<T>() inside an
Effect.
Calling these extensions without a BetterEffectScope produces a
Flutter error explaining how to install one.
Existing ViewModel base classes #
If a ViewModel already extends another ChangeNotifier base, use
EffectCommandOwner instead of EffectViewModel:
final class HomeViewModel extends ChangeNotifier with EffectCommandOwner {
HomeViewModel(EffectCommands commands) {
load = ownCommand(commands(_load));
}
late final EffectCommand0<HomeData, AppFailure> load;
Effect<HomeData, AppFailure> _load() => loadHome();
}
Owned Commands are disposed in reverse creation order when the ViewModel is disposed.
Observability #
Pass an observer to the bootstrap, provider, or EffectCommands
factory to observe transitions from every Command created there:
runApp(
BetterEffectBootstrap(
module: appModule,
observer: (transition) {
debugPrint('$transition');
},
builder: (_) => const App(),
),
)
EffectCommandTransition contains the previous state, current state,
timestamp, and optional debugLabel. A command-specific
stateObserver can project a successful value into ViewModel state.
Observer failures are reported through FlutterError; they do not
replace the Command's typed outcome.
Testing #
Test the real Runtime → Command → ViewModel flow and replace only the services that need to differ:
final runtime = await appModule
.overrideWith([
.instance<GreetingService>(FakeGreetingService()),
])
.start();
final viewModel = GreetingViewModel(EffectCommands(runtime));
final exit = await viewModel.greet.execute('Dart');
expect(exit, isA<ExitSuccess<String, AppFailure>>());
viewModel.dispose();
await runtime.close();
For widget tests, BetterEffectProvider.value is convenient because
the test owns the Runtime:
await tester.pumpWidget(
BetterEffectProvider.value(
runtime: runtime,
child: const MaterialApp(home: GreetingScreen()),
),
);
Remember to dispose ViewModels created directly in a test and close Runtimes that the test owns.
Example application #
The repository includes a complete task application demonstrating:
- contextual Repository/Service resolution;
- typed failure propagation;
- initial loading and refresh;
- one-shot SnackBars;
drop,latest, andqueueconcurrency;- ViewModel-owned Command disposal;
- a long-lived Runtime without a global injector.
Run its tests with:
cd example
flutter pub get
flutter test
Generate local platform folders and run it interactively with:
flutter create . --platforms=android,ios,web,linux,macos,windows
flutter run
Boundaries and limitations #
- This package does not provide a second Result type; it re-exports the core API.
- It does not provide a global service locator.
- It does not replace a general state-management package.
- Arbitrary Dart Futures cannot be forcefully cancelled. Cancellation is
ownership interruption plus an optional cooperative
onCancelhook. latestprevents stale state updates, but older underlying work may continue until its Future completes.- A Runtime should have one clear owner. Choose the owning bootstrap/provider
constructor or
.valuefor an externally owned Runtime, not both. - Business code should normally communicate through ViewModels and Effects rather than resolving services directly from Widgets.
API reference #
Develop this package #
From the package directory:
flutter pub get
dart format .
flutter analyze --fatal-infos
flutter test
flutter pub publish --dry-run
The package check script also validates the example application:
./tool/check.sh