Tutorials

Combine several UseCases for one screen

Aggregate independent snapshots at the presentation boundary while leaving atomic business coordination outside MVP.

v1.1.0Intermediate

Combine several UseCases for one screen

A profile screen can load data and save edits through separate UseCases. MVP may present both without pretending they form one business transaction.

The resulting flow is:

text
LoadProfileUseCase.state ─┐
                         ├─> ProfileModel -> ProfilePresenter -> ProfileViewState
SaveProfileUseCase.state ─┘                         └──────────> ProfileSaved effect

1. Put both snapshots in Model

dart
final class ProfileModel {
  const ProfileModel({
    required this.profile,
    required this.save,
    required this.reload,
    required this.saveName,
  });

  final UseCaseSnapshot<ProfileState> profile;
  final UseCaseSnapshot<SaveProfileState> save;
  final void Function() reload;
  final void Function(String name) saveName;
}

Model contains snapshots and allowed operations, not the UseCase instances. Presenter therefore cannot register commands, close a UseCase, or bypass its public input boundary.

2. Observe both sources

ModelBinding.changes contains both replay-latest streams. After either emits, read() obtains both current snapshots. Presenter receives one complete feature input instead of a partial event.

dart
final ModelBinding<ProfileModel> binding = ModelBinding<ProfileModel>(
  read: () => ProfileModel(
    profile: loadProfile.state,
    save: saveProfile.state,
    reload: () => loadProfile.add(const LoadProfile()),
    saveName: (name) => saveProfile.add(SaveProfile(name)),
  ),
  changes: <Stream<Object?>>[loadProfile.stream, saveProfile.stream],
);

3. Derive one screen policy

Presenter can combine loading, saving, current name, status text, and button policy into one ProfileViewState. The View never asks which UseCase is busy and does not merge two subscriptions.

dart
final class ProfilePresenter
    extends FlutterPresenter<ProfileModel, ProfileViewState, ProfileEffect> {
  void reload() => model.reload();

  void saveExampleName() => model.saveName('Grace Hopper');

  @override
  ProfileViewState buildViewState(BuildContext context) {
    final bool loading =
        model.profile.phase == UseCaseExecutionPhase.processing;
    final bool saving = model.save.phase == UseCaseExecutionPhase.processing;
    return ProfileViewState(
      title: model.profile.state.name.isEmpty
          ? 'Profile'
          : model.profile.state.name,
      status: loading
          ? 'Loading profile…'
          : saving
          ? 'Saving ${model.save.state.name}…'
          : 'Profile is ready',
      isLoading: loading,
      isSaving: saving,
      canSave: !loading && !saving,
    );
  }
}

4. Emit effects only for meaningful transitions

Replay-latest streams and explicit initial reads can produce equivalent snapshots. Compare previous and current save phases before emitting ProfileSaved; do not emit it unconditionally from onModelChanged.

dart
@override
void onModelChanged(ProfileModel previous, ProfileModel current) {
  final bool saveJustFinished =
      previous.save.phase != UseCaseExecutionPhase.finished &&
      current.save.phase == UseCaseExecutionPhase.finished &&
      current.save.result == UseCaseExecutionResult.completed;
  if (saveJustFinished) {
    emitEffect(ProfileSaved(current.save.state.name));
  }
}

5. Attach one View to the composed boundary

dart
MvpView<ProfileModel, ProfileViewState, ProfileEffect, ProfilePresenter>(
  model: binding,
  createPresenter: ProfilePresenter.new,
  onEffect: (context, effect) {
    if (effect case ProfileSaved(:final name)) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('$name was saved.')),
      );
    }
  },
  builder: (context, viewState, presenter) => ProfileScreenBody(
    viewState: viewState,
    onReload: presenter.reload,
    onSave: presenter.saveExampleName,
  ),
)

ProfileScreenBody consumes presentation-ready values and callbacks. It does not import UseCase Forge or inspect execution phases.

6. Keep business coordination where it belongs

This screen-level aggregation is valid when loading and saving remain independent operations. If both must commit atomically, create one coordinating business boundary. Ark MVP neither requires nor manufactures that UseCase.

7. Close the actual owners

The feature owner closes both UseCases. MvpView closes one Presenter. Model and ViewState are immutable values and own no resources.