Modelling App State
Immutability, freezed, sealed unions, equality, optimistic updates, state restoration
- 01
A teammate moved every value out of setState into one app-wide store "for consistency". What does that cost you?
EasyEphemeral state is anything no one outside the widget needs to read, and hoisting it into a shared store costs you free disposal, cheap rebuilds and screen independence while buying nothing.
// ✅ Ephemeral: nobody outside asks for it, and it dies with the route class _SearchFieldState extends State<SearchField> { final _controller = TextEditingController(); bool _showClear = false; @override … - 02
Your list widget sorts state.items in place before it builds. What breaks, and why didn't the compiler stop you?
Easyfinalfreezes the reference, not the contents, sosort()reorders the one list every holder shares — including the state object your store still believes is unchanged.class FeedState { const FeedState({this.items = const []}); final List<Item> items; // final = the reference, not the contents } // ❌ reorders the one list every holder shares — silently, with no notification … - 03
Changing one city name means hand-writing copyWith through four nested objects, and a field gets dropped every few weeks. What do you do?
MediumEither stop nesting — flatten the state and key the parts by id — or let a generator write the copies, because hand-written
copyWithchains are exactly where fields go missing.// ❌ hand-written, four levels deep — one dropped field per refactor final next = state.copyWith( user: state.user.copyWith( address: state.user.address.copyWith(city: 'Almaty'), ), ); … - 04
Your state class implements ==, yet the screen rebuilds on every emit even when the list holds exactly the same items. Why?
MediumListhas no value equality in Dart —==on a list is identity — so two lists built separately are never equal no matter what they contain, and your==is comparing references.class FeedState { const FeedState({this.items = const [], this.loading = false}); final List<Item> items; final bool loading; // ❌ what this class had: List.== is identity, so a freshly built list … - 05
Your screen owns seven small notifiers, and now changing the filter must also reset the page and clear the selection. What breaks?
MediumNothing makes the three writes land together, so the tree can rebuild between them and render a combination that is not a legal state — page 4 of a filter that has one page.
// ❌ three notifiers, three notifications, inconsistent frames in between final filter = ValueNotifier(Filter.all); final page = ValueNotifier(1); final selected = ValueNotifier(const <ItemId>{}); void applyFilter(Filter f) { … - 06
The cart total is stored as a field on the state and goes stale the moment a promo code is applied. Where should it have lived?
MediumAnything computable from the other fields should not be a field — derive it in a getter and the object cannot disagree with itself.
// ❌ total stored as a field: a second source of truth for one fact class CartState { const CartState({required this.lines, this.promo, required this.total}); final List<Line> lines; final Promo? promo; final double total; … - 07
A user taps the star twice fast, two saves are in flight, and the row settles on the wrong value. How do you make the result deterministic?
MediumOrder the state by when the user decided, not by when the network answered: tag every intent with an id and discard any reply whose id is no longer the newest.
class FavoriteCubit extends Cubit<bool> { FavoriteCubit(this._api, {required bool initial}) : super(initial); final Api _api; int _requestId = 0; … - 08
Product wants the like button to feel instant on a bad connection. How do you model the state so a failed save does not leave a lie on screen?
MediumApply the change locally at once, keep the value you replaced inside the state next to the pending row, and put it back — visibly — when the server refuses.
enum Sync { synced, pending, failed } class Post { const Post({required this.id, required this.liked, this.sync = Sync.synced}); final String id; final bool liked; … - 09
A pull-to-refresh fires while page three is still loading and the list ends up with duplicate rows — how would you model the paging state?
MediumPut the paging position in the state as data — the cursor the next page continues from — and make every response prove it still belongs to the state it lands in.
class PagedState<T> { const PagedState({ this.items = const [], this.nextCursor, this.isLoadingMore = false, this.isRefreshing = false, … - 10
The user renames themselves on the profile screen, but the drawer header and two other tabs still show the old name until a restart. Why?
MediumBecause each of those screens took its own copy of the name, and a copy has no way to hear about an edit — the value needs exactly one owner that every reader watches.
// each screen keeps its own copy: the edit reaches exactly one of them class _HeaderState extends State<Header> { late String _name; @override void initState() { … - 11
In a four-step signup flow, where does the half-filled data live, when does it become a real account, and what happens if the user backs out at step three?
MediumIn one draft object owned by the flow — not by any single step, and not by the domain model it will eventually become.
// everything optional, nothing validated: this is what the wizard edits class SignupDraft { const SignupDraft({this.email, this.password, this.displayName, this.plan}); final String? email; final String? password; … - 12
The OS killed the app in the background and the user comes back to a blank checkout screen — what do you restore, and what must you never restore?
HardRestore where the user was and what they typed, re-fetch everything the server owns, and never restore credentials or anything that claims an action already happened.
class _CheckoutPageState extends State<CheckoutPage> with RestorationMixin { // serialised into the OS bucket, so they survive process death final RestorableTextEditingController _note = RestorableTextEditingController(); final RestorableString _orderId = RestorableString(''); @override … - 13
QA keeps filing "spinner on top of a paid order" and the state is four booleans — how do you re-model the flow so that bug cannot be written?
HardReplace the booleans with a closed set of named states plus one total transition function, so the illegal combination has no way to be spelled.
sealed class CheckoutState {} final class Idle extends CheckoutState {} final class Submitting extends CheckoutState {} final class AwaitingConfirmation extends CheckoutState { AwaitingConfirmation(this.paymentId); … - 14
You pop a screen, reopen it, and it shows the previous visit's filters — then an old response overwrites the new list. What is wrong with the scoping?
HardThe state holder outlived the screen that owned it: it was created above the route — a singleton, or a provider at app scope — so popping disposed the widgets and nothing else.
// created above the route: one instance for the life of the process final catalogStore = CatalogStore(repo); // top-level singleton // created by the route, disposed with it GoRoute( path: '/catalog', … - 15
After an update the app crash-loops on launch because the state saved by the old version no longer parses — how should you have versioned it?
HardWith a version number stored next to the payload, a chain of small migrations from every old shape to the current one, and a load path where an unreadable blob is discarded rather than fatal.
const _currentVersion = 3; typedef Migration = Map<String, dynamic> Function(Map<String, dynamic>); // keyed by the version each one migrates FROM final _migrations = <int, Migration>{ … - 16
The only tests for this feature pump widgets, take four minutes and flake — how do you get the state logic under test without a widget tree?
HardSplit the part that decides from the part that performs: a pure transition you can call directly, with time, IO and randomness injected rather than reached for.
// the decision: pure, synchronous, no dependencies at all SearchState next(SearchState s, SearchEvent e) => switch ((s, e)) { (_, QuerySubmitted(:final query)) => Loading(query), (Loading(:final query), ResultsArrived(:final items, :final at)) => Ready(query, items, at), (Loading(), SearchFailed(:final error)) => Failure(error), …