State
Motivation
Most programs have state — scores, inventories, counters, configuration that changes as the program runs. The usual approach in Dart is to keep that state in a mutable variable and update it in place. But mutable shared state is one of the most common sources of bugs, especially as programs grow: functions that silently change state that a caller expected to remain the same, or code that reads stale data because an update happened in the wrong order.
The natural alternative is to make state explicit: functions take the current state as a parameter and return a new state alongside their result. No mutation, no hidden side effects.
The problem with that approach is threading. Once you have a sequence of operations that each transform the state, you end up manually passing the state from one step to the next:
final class GameStatePlain {
final int health;
final int gold;
final List<String> inventory;
const GameStatePlain({
required this.health,
required this.gold,
required this.inventory,
});
}
GameStatePlain takeDamagePlain(GameStatePlain state, int amount) => GameStatePlain(
health: state.health - amount,
gold: state.gold,
inventory: state.inventory,
);
GameStatePlain collectGoldPlain(GameStatePlain state, int amount) => GameStatePlain(
health: state.health,
gold: state.gold + amount,
inventory: state.inventory,
);
GameStatePlain pickUpItemPlain(GameStatePlain state, String item) => GameStatePlain(
health: state.health,
gold: state.gold,
inventory: [...state.inventory, item],
);
bool isAlivePlain(GameStatePlain state) => state.health > 0;
bool exploreForestPlain(GameStatePlain initial) {
final s1 = takeDamagePlain(initial, 15);
final s2 = collectGoldPlain(s1, 30);
final s3 = pickUpItemPlain(s2, 'Herbal Remedy');
return isAlivePlain(s3);
}
This works, but it has friction. Every function in the chain must accept and
return the full state. Every call site must name intermediate states and pass
them correctly — using s1 where you meant s3 is a real mistake that the
compiler won't catch. Reusing sequences of operations requires either
duplicating the threading logic or wrapping it in another function.
State<S, A> solves this by making the threading itself automatic. A
State<S, A> is a description of a computation that:
- Takes a current state of type
S - Produces a result of type
A - Returns a new state of type
S
Operations compose with flatMap, and the state is wired through the chain
without any explicit passing. The only time you interact with the state directly
is when you run the computation at the end.
Modelling State
Here is the game state type we will use throughout this page, along with an initial value:
final class GameState {
final int health;
final int gold;
final IList<String> inventory;
const GameState({
required this.health,
required this.gold,
required this.inventory,
});
GameState copy({int? health, int? gold, IList<String>? inventory}) => GameState(
health: health ?? this.health,
gold: gold ?? this.gold,
inventory: inventory ?? this.inventory,
);
String toString() => 'GameState(health: $health, gold: $gold, items: $inventory)';
}
The type is immutable — copy returns a new GameState rather than
modifying the original. This is important: State<S, A> works best when S
is immutable, so that each step produces a clearly distinct new state.
Defining Operations
Individual operations become State values. Each one describes how to update
the state and what value to return:
State<GameState, Unit> takeDamage(int amount) =>
State((s) => (s.copy(health: s.health - amount), Unit()));
State<GameState, Unit> collectGold(int amount) =>
State((s) => (s.copy(gold: s.gold + amount), Unit()));
State<GameState, Unit> pickUpItem(String item) =>
State((s) => (s.copy(inventory: s.inventory.appended(item)), Unit()));
State<GameState, int> currentHealth() => State((s) => (s, s.health));
State<GameState, bool> isAlive() => State((s) => (s, s.health > 0));
A few things to notice:
- Operations that only modify the state (like
takeDamage) returnState<GameState, Unit>— the result value carries no information, only the state transition matters. - Operations that read from the state (like
isAlive) return a meaningful result type —boolin this case — while leaving the state unchanged. currentHealthdoes both: it returns the health value as the result while not modifying the state.
These are ordinary values. You can store them, pass them to functions, return them from a method — without any computation having run yet.
Composing Operations
flatMap sequences operations together. Each step receives the result of the
previous one and contributes its own state transition. The chain reads left
to right, and the state flows through automatically:
State<GameState, bool> exploreForest() => takeDamage(15)
.flatMap((_) => collectGold(30))
.flatMap((_) => pickUpItem('Herbal Remedy'))
.flatMap((_) => isAlive());
State<GameState, bool> stormCastle() => takeDamage(40)
.flatMap((_) => collectGold(100))
.flatMap((_) => pickUpItem('Ancient Sword'))
.flatMap((_) => isAlive());
exploreForest is itself a State<GameState, bool> — a reusable description
of a sequence of actions. It can be embedded in larger compositions just like
any other operation. stormCastle is defined the same way and can be chained
after it.
Neither function runs anything when called. They build up a description that is executed only when you explicitly run it.
Running the Computation
State provides three ways to execute a computation against an initial state:
| Method | Returns |
|---|---|
run(s) | A tuple (S, A) — the final state and the result value |
runA(s) | Just the result value A |
runS(s) | Just the final state S |
Here is the full adventure composed and run:
void runAdventure() {
final initial = GameState(
health: 100,
gold: 0,
inventory: nil(),
);
final adventure = exploreForest().flatMap(
(bool survived) => survived ? stormCastle() : State.pure<GameState, bool>(false),
);
final (finalState, won) = adventure.run(initial);
print('Adventure complete! Won: $won');
print('Final state: $finalState');
// Adventure complete! Won: true
// GameState(health: 45, gold: 130, items: [Herbal Remedy, Ancient Sword])
}
The conditional in the middle — branching on whether the forest was survived —
is handled naturally with flatMap. The state accumulated up to that point
is threaded through whichever branch is taken.
If you only care about one part of the output, runA and runS are
convenient shortcuts:
void runParts() {
final initial = GameState(
health: 100,
gold: 0,
inventory: nil(),
);
// Only care about the result value
final survived = exploreForest().runA(initial); // true
// Only care about the final state
final stateAfter = exploreForest().runS(initial);
print('Survived: $survived');
print('State: $stateAfter');
}
API Summary
| Member | Description |
|---|---|
State(fn) | Construct from a function S → (S, A) |
State.pure(value) | A State that returns value and leaves the state unchanged |
map(f) | Transform the result value without touching the state |
flatMap(f) | Chain a follow-up operation; state flows through |
modify(f) | Apply f to the current state; result is the previous A |
transform(f) | Apply f to both the state and result, returning new (S, B) |
state() | Replace the result with the current state value |
run(s) | Execute with initial state s; return (finalState, result) |
runA(s) | Execute and return only the result |
runS(s) | Execute and return only the final state |