Skip to main content

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:

Manual State Threading
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:

Game State Model
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 Operations
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) return State<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 — bool in this case — while leaving the state unchanged.
  • currentHealth does 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:

Composing Operations
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:

MethodReturns
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:

Running the Computation
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:

runA and runS
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

MemberDescription
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