Navigate
Start a conversation

Nairobi, Kenya · Available for thoughtful work.

State that does not fight you

A practical way to keep Flutter screens honest: one source of truth, sealed UI state, and tests that describe behaviour instead of widgets.

Most Flutter codebases do not break because of a missing animation. They break because three layers disagree about what is true.

The symptom

A screen shows a loading spinner while the data is already there. A button submits twice because the guard lives in the widget. A logged-out user briefly sees a balance. None of these are exotic bugs; they are all the same bug wearing different clothes: state with more than one owner.

Pick one owner

In OneKitty I kept a single controller per feature slice. The widget layer reads, the controller decides, the repository fetches. If something needs to change, it travels one way:

abstract class WalletState {
  const WalletState();
}

class WalletLoading extends WalletState {
  const WalletLoading();
}

class WalletReady extends WalletState {
  const WalletReady(this.balance, this.entries);
  final int balance;
  final List<Entry> entries;
}

class WalletFailed extends WalletState {
  const WalletFailed(this.message);
  final String message;
}

Sealed states are boring on purpose. The compiler forces every switch to answer every case, so an unhandled failure is a build error instead of a blank screen in Nairobi traffic.

Guard actions in the controller, not the widget

A double submit is not a UI problem. It is a missing invariant.

Future<void> send() async {
  if (_state is! WalletReady || _sending) return;
  _sending = true;
  try {
    await repository.send(_amount);
    emit(repository.fetch()); // reload from the source of truth
  } finally {
    _sending = false;
  }
}

The button can still be disabled for feedback, but correctness does not depend on it.

Test behaviour, not trees

Widget tests that assert on find.byType break every redesign. Tests that assert on what the user can do survive:

testWidgets('a pending contribution blocks a second one', (tester) async {
  await tester.pumpWidget(App(fixture: delayedRepository()));
  await tester.tap(find.text('Contribute'));
  await tester.pump();
  expect(find.text('Sending…'), findsOneWidget);
  expect(find.text('Contribute'), findsNothing);
});

What I look for in review

  • Every screen has exactly one state object.
  • Loading, ready, empty, and failed are all representable.
  • Mutations re-read from the source of truth instead of patching local copies.
  • Errors are values with user-facing copy, not raw exceptions rendered into text.

That is the whole trick. Nothing clever — just making it impossible for the app to be in a state you never designed.

← All writing Start a conversation