-
Notifications
You must be signed in to change notification settings - Fork 0
How to work with use cases
This guide focuses on use cases, starting with the most straightforward example, and then continuing into advanced behavior.
The most basic use case handling is done via a simple Future
interface.
A use case can simply be called with an incoming parameter, and then provide all output as a Future<List<UseCaseResult<Out>>>
.
We use a list, because a single use case can potentially emit multiple events for every incoming parameter. A single UseCaseResult will contain either a value, or an error, should the use case have emitted an error at some point.
First, let's create a very basic use case.
We create one that takes an incoming parameter of type int
, and then outputs 3 int
values:
- first the incoming value as it is
- then the incoming value +1
- then the incoming value +2
class MyUseCase extends UseCase<int, int> {
@override
Stream<int> transaction(int param) async* {
yield param;
yield param + 1;
yield param + 2;
}
}
Next, we need to setup a manager (Cubit) which simply exposes a handler.
Inside that handler, the use case is invoked as a Future
:
class Manager extends Cubit<State> {
final MyUseCase _useCase;
Manager(this._useCase) : super(const State.initial());
Future<void> handleUseCase(int param) async {
final result = await _useCase(param);
for (var entry in result) {
if (entry.hasData) {
// emit a state
} else if (entry.hasError) {
// handle an error
}
}
}
}
This approach, while fairly simple, does have some considerations:
- what if the handler is invoked many times in sequence?
- we cannot cancel the previous use case invocation, as it runs as a Future
- we would need to add logic inside our manager to mitigate
A different approach is to consume the use case as a Stream
instead.
While more complex, it gives us fine-grained control on how and when events are processed,
we can also transform events if we want to, or even chain multiple use cases in sequence!
Currently, when using use cases as a Stream
, they also process events using switchMap,
this means that, whenever a new input is added via the handler from the Widget, then the current pipeline will stop and restart from the top using the freshest data.
To facilitate stream consumption, a helper class is provided for usage with Cubits or Blocs.
This helper is a mixin
called UseCaseBlocHelper
, in order to use it, we have to update our manager example:
class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
final MyUseCase _useCase;
Manager(this._useCase) : super(const State.initial());
}
Now, we can start using use cases as Stream
s instead.
There are 2 ways to do this, some use cases require input from a Widget
, in that case we still need a handler to expose,
another type is a use case which doesn't expect any new input, but instead emits whenever it needs to.
An example of the latter is the discovery engine, which internally decides when to emit new results, without expecting input.
Both situations work with a handler from the UseCaseBlocHelper mixin
:
- pipe: takes a use case, and returns a handler
- consume: takes a use case, with a starting value, and returns no handler
With our custom use case from above, we will chose the pipe
version:
class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
final MyUseCase _useCase;
final void Function(int param) _handleUseCase;
Manager(this._useCase) : super(const State.initial());
Future<void> handleUseCase(int param) async {
_handleUseCase(param);
}
@override
Future<void> initHandlers() async {
_handleUseCase = pipe(_useCase).fold(
onSuccess: (data) {
// emit a state
},
onFailure: HandleFailure((e, st) {
// handle an error
}),
);
}
}
onSucces
will be invoked with every successful output, and onFailure
with every exception.
We can also opt for consume
instead, this way, we won't get a handler which can be invoked, instead the use case is subscribed to once only, with initialData
.
In both cases, subscription
s will stop, once the Cubit
is being disposed. So there is no need for custom code that cancels for example:
class Manager extends Cubit<State> with UseCaseBlocHelper<State> {
final MyUseCase _useCase;
Manager(this._useCase) : super(const State.initial());
@override
Future<void> initHandlers() async {
consume(_useCase, initialData: 1).fold(
onSuccess: (data) {
// emit a state
},
onFailure: HandleFailure((e, st) {
// handle an error
}),
);
}
}
By default, onFailure
is very generic, but we can improve it by using matchers.
Assume we want to specifically match for ArgumentError
s:
onFailure: HandleFailure((e, st) {
// handle an error
}, matchers: {
On<ArgumentError>((e, st) {
// handle an ArgumentError
})
}),
onSuccess
and onFailure
can all return new states, but there are scenarios where we want tighter control over our state updates.
To achieve this, we can create a single state update handler, where we can compare a potential next state to the current state, and decide if we proceed with emitting the new one, or instead stay with the current one. An example here would be the loading state:
- if the current state is in a loading state, then any next state update should either be a filled state, or an error state
This state-machine behavior can be added via guard:
pipe(_useCase).fold(
onSuccess: (data) {
// emit a state
},
onFailure: HandleFailure((e, st) {
// handle an error
}),
guard: (nextState) {
// compare nextState to this.state, and finally return
// true if we want to pass nextState,
// false if we need to remain with this.state
return true;
}
);
Usually, we either pipe or consume a use case directly into a fold with onSuccess
and onFailure
.
We can however also choose to apply extra transformations along the way, say we want to log something, we can add a LoggerUseCase
step, or a CountlyUseCase
step even. Or we wish to discard any loading events from a previous use case, then we can simply use a where
Stream transformer.
If we wish to transform over another use case, then we can use followedBy
as a transformer.
It takes the output from a previous use case as the input of a next use case.
pipe(_useCase)
.transform(
(out) => out
.followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 1, 2, 3
.where((it) => it >= 3)
.followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 3 only
.fold(
onSuccess: (data) {
// emit a state
},
onFailure: HandleFailure((e, st) {
// handle an error
}),
);
Once transform and use case chaining takes place, we may not want every single event which traverses the transform pipeline to hit onSuccess
or onFailure
at the end.
What if we follow up with another use case, but that use case can only run on the final event of the previous use case?
Take a use case which fetches data from an external end point, followed by another one which deserializes that data into Object
s.
The first use case may emit progress updates, which are useless for the second one, but those progress events to require a state update to loading state.
Therefore, an extra transformer can be used, which swallows events and emits a new state directly:
pipe(_useCase)
.transform(
(out) => out
.followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 1, 2, 3
.maybeResolveEarly(
condition: (it) => it < 3,
stateBuilder: (it) => const State.loading())
.followedBy(LoggerUseCase((it) => 'I have emitted $it')) // logs 3 only
.fold(
onSuccess: (data) {
// emit a state
},
onFailure: HandleFailure((e, st) {
// handle an error
}),
);
This transformer replaces the where transformer from before, it still swallows the first 2 events [1, 2]
, but instead of just ignoring them,
it instead triggers a next state (State.loading()
), which of course also passes guard
, if provided.