The previous article ended on a cliffhanger.
We had a donation use case for a twitch.tv-like site. All logic was extracted
out of the states, so the whole action was a flat, readable list of steps:
class DonateToStreamerUseCase {
donateToStreamer(amount) async {
try {
activeChatState.anounceDonation();
walletState.subtractFunds(amount);
viewerState.addExperience(10);
await submitDonationRepository.submit(amount);
snackBarService.displaySnackbar('Donation successful!');
} catch (error) {
walletState.addFunds(amount);
viewerState.subtractExperience(10);
activeChatState.removeDonationAnouncement();
snackBarService.displaySnackbar('Oops, something went wrong');
}
}
}
Then the requirements grew. Every time we award experience we must also check
whether the user levels up. If they do: unlock new features, play a level up
animation, track an analytics event, make an API request.
And experience is awarded in a dozen places: donations, sent messages, daily
logins, finished streams.
We can't copy that block into a dozen use cases. And if we hide it in a
LevelingService, we are back where we started: viewerState.addExperience(10)
silently plays animations and fires requests.
So how do we reuse complicated logic without losing predictability?
By moving the reusable parts of use cases into two new kinds of classes:
Actions and Reactions. The use case stays and keeps what belongs to the
user's intent — navigation, snackbars, the error surface. The reusable state
changes move out of it, and each one gets one of the two names.
Actions change one state
An Action performs one side effect (usually a network request) and writes
to one state. Any number of state changes is fine, as long as they all land
on that one state.
class AddExperienceAction {
Future<void> call(int amount) async {
try {
viewerState.setLoading();
final updatedExperience = await addExperienceRepository.add(amount);
viewerState.setExperience(updatedExperience);
} catch (error) {
viewerState.setFailure(error);
rethrow;
}
}
}
AddExperienceAction adds experience and puts it into ViewerState, including
the loading and failure states along the way. That is all. It does not announce
anything in the chat, does not touch the wallet, does not display a snackbar.
What you see in the name is what you get.
This is the important part: you do not have to open an Action to know what it
does. One side effect and one state is exactly as much as a name can carry.
The name tells you which state changes, and the contract tells you that nothing
else does.
Reactions outgrow those bounds
A Reaction is an Action that outgrew the two bounds: it may perform more than
one side effect, and it may write to more than one state. Everything else is
the same — it is a reusable unit of state change, and any caller that needs
that change calls it instead of re-implementing it.
What it gives up is predictability from the name. A Reaction's name cannot tell
you what it changes, because there is more than one answer.
Here is the level-up problem from the cliffhanger:
class AwardExperienceReaction {
Future<void> awardXp(int amount) async {
final previousLevel = viewerState.level;
// Adds experience to viewerState. Enough experience
// raises viewerState.level — that is what we compare below.
await addExperienceAction(amount);
if (viewerState.level > previousLevel) {
final unlockedFeatures = await getUnlockedFeaturesRepository(
viewerState.level,
);
unlockedFeaturesState.set(unlockedFeatures);
levelUpAnimation.show(viewerState.level);
analyticsTracker.trackLevelUp(viewerState.level);
}
}
}
A Reaction may call Actions, but combining Actions is not what defines it. A
Reaction can make requests, run calculations, branch, wait, call services, roll
changes back, or manipulate states directly. What defines it is how many side
effects and states it owns — not how it carries them out. Even a class with a
single state can be a Reaction, if it performs two side effects: nobody can
guess the second one from the name.
One boundary stays with the use case though:
A Reaction owns shared consequences. It never owns the user's intent.
Navigation, snackbars and other direct answers to the user stay in the use
case.
The line between intent and consequence is not always obvious, so here is the
test: ask who wants this to happen. A consequence must happen everywhere, no
matter which screen triggered it — leveling up looks the same whether the
experience came from a donation or a daily login. An intent is what one
particular screen wants to say to its user right now — the donation screen
shows "Donation successful!", while a daily login shows nothing at all. Same
Reaction, different answers to the user — so the answer cannot live inside the
Reaction.
That is why the snackbars stay in DonateToStreamerUseCase below, while the
level-up consequences live in AwardExperienceReaction.
Now the donation use case reuses that logic in one line, and still keeps its
optimistic UI:
class DonateToStreamerUseCase {
donateToStreamer(amount) async {
try {
// Displays "$user donated $amount to the streamer!" in the chat.
activeChatState.anounceDonation(amount);
walletState.subtractFunds(amount);
await submitDonationRepository.submit(amount);
await awardExperienceReaction.awardXp(10);
snackBarService.displaySnackbar('Donation successful!');
} catch (error) {
walletState.addFunds(amount);
activeChatState.removeDonationAnouncement();
snackBarService.displaySnackbar('Oops, something went wrong');
}
}
}
Notice the order. The chat announcement and the wallet update happen before the
request — the user sees the result instantly — and the catch block reverts
the announcement and the wallet if the request fails. Experience is awarded
only after the backend confirmed the donation, so it never needs reverting.
To be clear: that order is not a rule of the architecture. It is a business
decision we made as developers — the wallet must react instantly, experience
can wait a second. A different product would pick a different order.
And that is the whole point of a use case. When the user does something, one
use case activates, and inside it we are free to do whatever the task needs,
in whatever order the task needs: change a state, fire a request, call a
Reaction, show feedback. It is just a flat list of steps that we arrange
however we want — and everything that happens is on this list.
The difference is for humans, not for the compiler
Technically an Action and a Reaction are both ordinary classes. Nothing enforces
the distinction. It is a convention, and the convention exists for one reason:
to tell the next developer how much they need to read.
- Action — one side effect, one state. Trust the name, move on.
- Reaction — more than that. Open it and read it.
You can see the convention working in any use case that composes both:
class OpenAdminDashboardUseCase {
Future<void> call() async {
await getLogsAction();
await getUserAction();
await updateAuthenticationDataSecurelyReaction();
}
}
getLogsAction needs no explanation. updateAuthenticationDataSecurelyReaction
is exactly the name you would have to open the class to understand — and the
suffix told you so before you opened anything.
When a bug report says "donating sometimes plays the level up animation twice",
you don't grep the codebase. You scan DonateToStreamerUseCase, ignore
everything named ...Action, open AwardExperienceReaction, and you are
already looking at the bug.
The suffix is a warning label. Reactions are allowed to be complicated,
precisely because they announce it. That makes debugging easier.
Reducing the Action boilerplate
One state per Action means many small classes. A feature folder reads like a
table of contents:
chat_room_messages_cubit.dart // state that holds data
get_chat_room_messages_action.dart
send_chat_room_message_action.dart
delete_chat_room_message_action.dart
The file list is the list of things that can happen to that data. But most of
these classes are the same class. Every "get" Action does exactly what
AddExperienceAction did above: set loading, call a repository, save the
result, set failure.
Since the shape is identical every time, write it once as a generic:
class GetChatRoomMessagesAction
extends GetAction<ChatRoomMessagesState, List<Message>> {
GetChatRoomMessagesAction({required super.state, required super.repo});
}
GetAction contains the mechanics; the subclass only names the state and the
repository. The same trick covers the other repetitive workflows:
-
GetAction— load data into one state. -
UpdateAction— update one state optimistically, roll back on failure. -
DeleteAction— delete, and restore the previous data if the request fails.
The bounds did not move: each of these classes still owns one side effect and
one state, and its name still describes what happens. We removed the typing,
not the rule.
Do's and don'ts
The bounds are easy to state and easy to break by accident. The common breaks:
// BAD: an "Action" that writes to a second state.
class DeleteMessageAction {
Future<void> call(Message message) async {
await deleteMessageRepository(message.id);
chatRoomMessagesState.remove(message);
unreadCountState.decrement(); // <- second state. This is a Reaction now.
}
}
Rename it to a Reaction, or keep it an Action and let the caller update the
second state.
// BAD: an "Action" with a hidden second side effect.
class GetProfileAction {
Future<void> call() async {
// <- Nobody can guess this from the name. Worse: if tracking
// throws, the profile silently never loads.
analyticsTracker.track('profile_opened');
profileState.setLoaded(await getProfileRepository());
}
}
This is the unpredictable state problem from the previous article, one layer
up. The name promises "get profile into profile state" and the class does more.
Move the tracking to the use case that opens the profile, or turn the class
into a Reaction.
// BAD: a Reaction that owns the user's intent.
class SendMessageReaction {
Future<void> call(String text) async {
await sendMessageAction(text);
await getUnreadCountAction();
snackBarService.displaySnackbar('Sent!'); // <- use case's job.
navigator.pop(); // <- use case's job.
}
}
The state changes are fine — two states, correctly a Reaction. But navigation
and feedback answer the user directly, and different screens will want
different answers — a chat page pops back, a support widget stays put. Keep
them in the use case.
Could the snackbar live in the Reaction? Honestly — yes, if "Sent!" is truly
wanted everywhere a message is sent, moving it there is a legitimate call.
There is no technical difference: an Action, a Reaction and a use case are all
just classes. Every boundary in this article is a contract defined and
enforced by humans, for humans — the compiler will never stop you. That is
exactly why the names matter: they are the contract's only enforcement.
In short
- Use an Action for one side effect landing in one state. The name is the documentation.
- Use a Reaction for reusable logic with more side effects or more states than one name can carry. The name is a warning to go and read it.
Logic gets reused. States stay dumb. And you can still tell, by reading a single
use case, exactly what your application is about to do.
Top comments (0)