flutter_stasis 0.4.0 copy "flutter_stasis: ^0.4.0" to clipboard
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 #

pub.dev license: MIT

Flutter layer of the Stasis ecosystem. Provides StasisViewModel, reactive widgets, and ephemeral UI events.

For the pure-Dart contracts (StateObject, Command, CommandPolicy) see flutter_stasis_core. For dartz Either integration see flutter_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),
);

setError is the automatic fallback for execute when onError is 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 SafeData with manageSafeData(...).
  • Keep SafeData out of Equatable.props.
  • Use commandKey when 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() on StasisViewModel (notify without replacing state instance)
  • batch() on StasisViewModel (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:

  1. keep lifecycle semantics explicit and predictable
  2. refine async UX patterns in docs (initial load vs refresh)
  3. evolve controlled mutable-resource story on top of invalidate()
  4. continue improving examples and test ergonomics

License #

MIT

4
likes
150
points
404
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

State management for Flutter built around explicit lifecycle, standardised async execution, and ephemeral UI events.

Repository (GitHub)
View/report issues

Topics

#state-management #mvvm #flutter #architecture

License

MIT (license)

Dependencies

flutter, flutter_stasis_core

More

Packages that depend on flutter_stasis