flutter_stasis 0.4.0
flutter_stasis: ^0.4.0 copied to clipboard
State management for Flutter built around explicit lifecycle, standardised async execution, and ephemeral UI events.
flutter_stasis #
Flutter layer of the Stasis ecosystem. Provides StasisViewModel, reactive widgets, and ephemeral UI events.
For the pure-Dart contracts (
StateObject,Command,CommandPolicy) seeflutter_stasis_core. For dartzEitherintegration seeflutter_stasis_dartz.
Installation #
dependencies:
flutter_stasis: ^0.4.0
StasisViewModel #
Base class for all ViewModels. Requires an initial StateObject and exposes helpers to transition lifecycle and execute async commands.
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects)
: super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: _getProjects,
onLoading: setLoading,
onError: setError,
onSuccess: setSuccess,
);
void setFilter(ProjectFilter f) =>
update((s) => s.copyWith(filter: f));
}
State helpers #
setLoading() // → LoadingState
setSuccess(data) // → SuccessState(data)
setError(failure) // → ErrorState(failure)
setInitialState() // → InitialState
update((s) => s.copyWith(...)) // update fields, keep lifecycle
Combine lifecycle transition + field update in one call with withUpdate:
setSuccess(
audioFile,
withUpdate: (s) => s.copyWith(waveform: audioFile.waveform),
);
setErroris the automatic fallback forexecutewhenonErroris not provided.
execute #
Single entry point for all async actions.
onSuccess is required — you must decide what to do with the result.
onError is optional — when omitted, setError is called automatically.
onLoading is optional — pass setLoading when you want a loading indicator.
// Full form
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
onError: setError, // optional, this is the default behaviour
onLoading: setLoading,
);
// Minimal — onError falls back to setError automatically
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
);
// Custom error handling — override the default
Future<void> load() => execute(
command: _getProjects,
onSuccess: setSuccess,
onError: (f) => emit(ShowSnackBarEvent(f.message)),
);
// With concurrency policy
Future<void> search(String query) => execute(
command: TaskCommand(() => _searchUseCase(query)),
onSuccess: setSuccess,
onLoading: setLoading,
policy: CommandPolicy.restartable,
policyKey: 'search',
);
Use a stable policyKey for droppable, sequential, and restartable.
restartable suppresses stale callbacks but does not cancel underlying I/O.
commandKey is separate from policyKey. Use it when managed runtime fields
such as SafeData should react to a specific command completion:
Future<void> login() => execute(
command: _loginUseCase,
onSuccess: setSuccess,
onLoading: setLoading,
commandKey: 'login',
);
See flutter_stasis_core for Command, CommandPolicy and result composition.
Lifecycle #
class _ScreenState extends State<MyScreen> {
late final vm = MyViewModel(getIt());
@override
void dispose() {
vm.dispose(); // clears state, events, and concurrency scope
super.dispose();
}
}
Runtime-safe data #
SafeData<T> lets a ViewModel manage short-lived or sensitive values with
explicit cleanup rules.
class LoginState extends StateObject<AuthFailure, Session, LoginState> {
const LoginState({
required super.state,
required this.password,
required this.otpCode,
});
final SafeData<String> password;
final SafeData<String> otpCode;
@override
LoginState withState(ViewModelState<AuthFailure, Session> state) =>
LoginState(
state: state,
password: password,
otpCode: otpCode,
);
@override
List<Object?> get props => [state];
}
class LoginViewModel extends StasisViewModel<AuthFailure, Session, LoginState> {
LoginViewModel()
: super(
LoginState(
state: const InitialState(),
password: SafeData.memoryOnly(
initialValue: '',
clearOnCommandSuccess: {'login'},
),
otpCode: SafeData.memoryOnly(
expiresAfter: const Duration(seconds: 30),
clearOnCommandSuccess: {'verify-otp'},
),
),
) {
manageSafeData(state.password);
manageSafeData(state.otpCode);
}
void updatePassword(String value) => state.password.set(value);
Future<void> login() => execute(
command: _loginUseCase,
onSuccess: setSuccess,
onLoading: setLoading,
commandKey: 'login',
);
}
Rules of thumb:
- Register each
SafeDatawithmanageSafeData(...). - Keep
SafeDataout ofEquatable.props. - Use
commandKeywhen cleanup should happen after success or error. - Treat this as safe handling, not absolute memory security.
StasisBuilder #
Rebuilds whenever the StateObject changes:
StasisBuilder(
listenable: vm.stateListenable,
builder: (context, state, _) {
if (state.isLoading) return const Loader();
if (state.isError) return ErrorBanner(state.errorMessage!);
return ProjectList(state.projects ?? []);
},
);
StasisSelector #
Rebuilds only when the selected value changes. Use this inside StasisBuilder to avoid rebuilding heavy subtrees:
// Rebuilds only when isDragging changes
StasisSelector<AudioState, bool>(
listenable: vm.stateListenable,
selector: (state) => state.isDragging,
builder: (context, isDragging, _) => DropZone(active: isDragging),
);
Custom equality for complex types:
StasisSelector<AudioState, List<double>>(
listenable: vm.stateListenable,
selector: (state) => state.waveform,
equals: (a, b) => a.length == b.length,
builder: (context, waveform, _) => WaveformWidget(points: waveform),
);
StasisEventListener #
One-shot events that should never be stored in state — navigation, snackbars, dialogs.
The ViewModel dispatches; the View reacts:
// Define your events
final class ShowSnackBarEvent extends UiEvent {
const ShowSnackBarEvent(this.message);
final String message;
}
final class PopEvent extends UiEvent {
const PopEvent();
}
// ViewModel dispatches
void onSaved() {
emit(const ShowSnackBarEvent('Saved successfully'));
emit(const PopEvent());
}
// View reacts
StasisEventListener(
stream: vm.events,
onEvent: (context, event) async {
switch (event) {
case ShowSnackBarEvent(:final message):
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
case PopEvent():
Navigator.of(context).pop();
}
},
child: ...,
);
Full example #
// state
class CounterState extends StateObject<String, int, CounterState> {
const CounterState({required super.state});
int get count => dataOrNull ?? 0;
@override
CounterState withState(ViewModelState<String, int> state) =>
CounterState(state: state);
@override
List<Object?> get props => [state];
}
// view model
class CounterViewModel extends StasisViewModel<String, int, CounterState> {
CounterViewModel()
: super(const CounterState(state: SuccessState(0)));
Future<void> increment() => execute(
command: TaskCommand(() async {
await Future.delayed(const Duration(milliseconds: 200));
return CommandSuccess((state.count) + 1);
}),
onSuccess: setSuccess,
onLoading: setLoading,
policy: CommandPolicy.droppable,
policyKey: 'counter_increment',
);
}
// screen
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
final vm = CounterViewModel();
@override
void dispose() {
vm.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: StasisBuilder(
listenable: vm.stateListenable,
builder: (_, state, __) => state.isLoading
? const CircularProgressIndicator()
: Text('${state.count}', style: const TextStyle(fontSize: 48)),
),
),
floatingActionButton: FloatingActionButton(
onPressed: vm.increment,
child: const Icon(Icons.add),
),
);
}
Usage patterns #
Stasis works regardless of how your use cases return results. Pick the pattern that fits your project.
Pattern 1 — Use case with dartz + adapter installed #
The most ergonomic path if you already use dartz. Import flutter_stasis_dartz and call executeEither directly with your use case.
// use case (your domain layer — untouched)
class GetProjectsUseCase {
GetProjectsUseCase(this._repository);
final ProjectRepository _repository;
Future<Either<AppFailure, List<Project>>> call() =>
_repository.getAll();
}
// view model
import 'package:flutter_stasis_dartz/flutter_stasis_dartz.dart';
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => executeEither(
command: _getProjects.call,
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Pattern 2 — Use case with dartz, no adapter #
If you use dartz but don't want to add flutter_stasis_dartz, convert the Either manually inside a TaskCommand. One extra line, no extra dependency.
// use case (same as above)
class GetProjectsUseCase {
Future<Either<AppFailure, List<Project>>> call() =>
_repository.getAll();
}
// view model — no adapter import needed
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: TaskCommand(() async {
final result = await _getProjects();
return result.fold(
(failure) => CommandFailure(failure),
(data) => CommandSuccess(data),
);
}),
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Pattern 3 — No dartz, no adapter #
Use cases return plain Future and throw on failure, or you catch errors yourself. Wrap everything in a TaskCommand and return CommandSuccess / CommandFailure directly.
// use case — plain Future, throws on error
class GetProjectsUseCase {
Future<List<Project>> call() => _repository.getAll();
}
// view model
class ProjectsViewModel
extends StasisViewModel<AppFailure, List<Project>, ProjectsState> {
ProjectsViewModel(this._getProjects) : super(ProjectsState.initial());
final GetProjectsUseCase _getProjects;
Future<void> load() => execute(
command: TaskCommand(() async {
try {
final data = await _getProjects();
return CommandSuccess(data);
} on NetworkException catch (e) {
return CommandFailure(AppFailure(e.message));
} catch (e) {
return CommandFailure(AppFailure('Unexpected error'));
}
}),
onSuccess: setSuccess, // onError omitted → setError called automatically
onLoading: setLoading,
);
}
Alternatively, if you want the use case to already speak CommandResult, implement Command<F, R> directly — then pass it straight to execute with no wrapper:
// use case implements Command directly
class GetProjectsUseCase implements Command<AppFailure, List<Project>> {
@override
Future<CommandResult<AppFailure, List<Project>>> call() async {
try {
final data = await _repository.getAll();
return CommandSuccess(data);
} on NetworkException catch (e) {
return CommandFailure(AppFailure(e.message));
}
}
}
// view model — cleanest form, no wrapper, no onError needed
Future<void> load() => execute(
command: _getProjects, // use case IS the command
onSuccess: setSuccess,
);
Runtime update #
Based on community feedback, flutter_stasis now includes a dedicated runtime notifier:
StasisNotifier<T>(internal runtime for state updates)invalidate()onStasisViewModel(notify without replacing state instance)batch()onStasisViewModel(coalesce multiple updates into a single notification)
Why this matters:
- keeps immutable update flow (
update,copyWith, lifecycle helpers) - unlocks advanced controlled scenarios where internal mutable data changes but state object replacement is unnecessary
- reduces noisy rebuild bursts when multiple updates happen together
FAQ from Reddit feedback #
"Can I do this with sealed classes in BLoC/Riverpod too?" #
Yes. Stasis does not claim this is impossible elsewhere.
The goal is to make the lifecycle (Initial/Loading/Success/Error) structural and always present in the base contracts, so teams start from a consistent baseline without re-deciding the pattern per feature.
"Data + loading at the same time is normal (refresh/lazy load)." #
Correct. A common pattern is to keep existing data visible while background refresh runs.
In Stasis, one explicit approach is:
Future<void> refresh() => execute(
command: _getItems,
onLoading: () => update((s) => s.copyWith(isRefreshing: true)),
onSuccess: (data) => setSuccess(
data,
withUpdate: (s) => s.copyWith(isRefreshing: false),
),
onError: (f) => setError(
f,
withUpdate: (s) => s.copyWith(isRefreshing: false),
),
);
This keeps the lifecycle explicit and keeps previous data visible.
"Isn't this two loading states?" #
It is two different UX intents:
- initial load (no data yet)
- background refresh (data already visible)
If your screen does not need that distinction, just use setLoading() and skip isRefreshing.
"Why events outside state?" #
You can store events in state, but then you need a consume/reset protocol.
Keeping ephemeral effects in UiEvent stream avoids accidental re-fire on rebuild (navigation, snackbars, dialogs).
"Should I migrate an already stable BLoC/Riverpod codebase?" #
Usually no.
Stasis is a better fit for greenfield projects or teams that want this specific trade-off from day one.
"Codegen-free forever?" #
Core design is currently codegen-free. Optional tooling can be explored later if it improves large-scale ergonomics without making codegen mandatory.
Additional examples (new APIs) #
Coalesce multiple updates with batch() #
void prepareSync() {
batch(() {
setLoading();
update((s) => s.copyWith(progress: 0, isSyncing: true));
});
}
Notify listeners without replacing state via invalidate() #
class AudioVm extends StasisViewModel<AppFailure, AudioData, AudioState> {
AudioVm() : super(const AudioState(state: InitialState()));
final List<double> waveformCache = <double>[];
void patchWaveform(List<double> chunk) {
waveformCache.addAll(chunk);
invalidate();
}
}
Prevent duplicated listeners with ownerKey + eventChannel #
StasisEventListener(
channel: vm.eventChannel,
ownerKey: 'home_shell',
onEvent: (context, event) async {
switch (event) {
case ShowSnackBarEvent(:final message):
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
},
child: child,
);
Explicit command keying for concurrency #
Future<void> toggleItem(String id) => execute(
command: TaskCommand(() => _toggleUseCase(id)),
onSuccess: (_) => load(),
policy: CommandPolicy.restartable,
policyKey: 'toggle_$id',
);
Roadmap focus (short term) #
Near-term priorities, based on real feedback:
- keep lifecycle semantics explicit and predictable
- refine async UX patterns in docs (initial load vs refresh)
- evolve controlled mutable-resource story on top of
invalidate() - continue improving examples and test ergonomics
License #
MIT