Ephemeral state belongs to one widget and dies with it (a field's focus, the selected tab, an animation). App state is shared between screens or must outlive them (the signed-in user, the cart, settings). Keep a value as local as its readers allow.
Ask three questions: does anything outside this widget read it, must it survive leaving the screen, does the backend care? Three "no" answers mean setState in a State is the right home
Local is cheaper: the value is disposed together with the widget, and a change rebuilds only that subtree
Hoisting everything into one store costs you: a reopened screen shows the last visit's search text, and every keystroke wakes listeners across the app
Promote when a second reader appears: lifting a field up later is a mechanical change; pushing app-wide state back down is the expensive direction
FlutterMiddle
Modelling App State
Immutability, freezed, sealed unions, equality, optimistic updates, state restoration
Questions closed
0 / 19
- 0Review
- 2Asked often
Questions
Immutable state is never changed in place: every update builds a new object, usually with copyWith. State holders detect a change by comparing the old state with the new one, and a mutated object compared with itself looks unchanged, so nothing rebuilds.
Change detection: bloc's emit and Riverpod notifiers skip a new state that compares equal to the current one; the same mutated instance always does
Safe sharing: a widget, a logger and an undo history can hold the same state without one of them changing it under the others
Old and new side by side: listenWhen in BlocListener and ref.listen receive previous and next, which only means something if previous was not overwritten
How: final fields, a const constructor, copyWith, and collections copied rather than mutated
A sealed class with one subtype per state, each carrying only its own data. Separate isLoading, error and data fields allow combinations that should never exist, such as a spinner over an error banner, and nothing forces the UI to handle every case.
Booleans multiply: three flags give eight combinations and only three or four are legal; the rest appear when two callbacks land in the wrong order
Each state carries its data: FeedLoaded has the items, FeedFailed has the message; you cannot read items that are still loading
**Exhaustive switch**: add a state and every switch that misses it stops compiling, instead of falling into an else that shows a blank screen
Refresh without blanking: when old rows must stay visible during a reload, give the loaded state an isRefreshing flag rather than going back to Loading; Riverpod's AsyncValue keeps the previous value for the same reason
History: before Dart 3 this took freezed unions; today a plain sealed class is enough
The wildcard _ catches every state the switch does not name, so OrdersStale falls into SizedBox.shrink() and renders nothing. The wildcard also switched off the exhaustiveness check, so adding the subtype compiled without a single error.
Why it compiles: a switch over a sealed type must cover every subtype, and _ covers all of them at once; when a new subtype appears, the compiler has nothing to report
The fix: name every subtype and drop the wildcard. The next new state then breaks the build with "The type 'OrdersState' isn't exhaustively matched by the switch cases" in every switch that has to handle it
The same hole: default: in a switch statement or an if (state is …) … else chain hides new states the same way
Catch it: when _ follows cases that already cover every subtype, the analyzer warns "This case is covered by the previous cases": delete the wildcard instead of ignoring it. A widget test per state catches the blank screen
sort() reorders the cubit's own list in place, so the emitted state changes behind the cubit's back and everyone holding it sees the new order. final only forbids reassigning the field; the List it points to stays mutable.
No notification: nothing was emitted, so other screens keep painting from a state object that has silently changed
Shared victims: a second screen that sorts by name, a previous state kept for undo, a test fixture: they all hold the same list
It can also crash: if the list was built with const [] or List.unmodifiable, sort throws Unsupported operation
The fix: sort a copy, [...state.items]..sort(...), or better, sort once in the cubit and emit the sorted list; build must never change its inputs
Make the mistake loud: wrap collections in List.unmodifiable in the state constructor, so a stray add or sort throws in debug and in tests
Override == and hashCode so two states with the same field values are equal (by hand, with Equatable, or generated by freezed). State holders and selectors compare states with == to decide whether to notify, so equality decides what rebuilds.
Without it: the default == is identity, so two identical states built separately are "different" and every emit rebuilds
Collections are the trap: two separately built lists with the same items are !=; a hand-written == needs listEquals, mapEquals or DeepCollectionEquality. Equatable and freezed compare collections deeply for you
**hashCode must agree**: equal objects need equal hashes, Object.hash(Object.hashAll(items), loading), or Set and Map lookups break
Choosing: Equatable for a few classes without codegen; freezed when you also want copyWith and JSON. Records like (a, b) compare structurally, but a list inside one still compares by identity
Mind the cost: deep-comparing 10 000 rows on every emit is real work; compare a version or updatedAt field when the list has a single owner
onlyInStock is missing from props, so a state that differs only in that flag is == to the current one. Bloc's emit drops a state equal to the current one, so the toggle stops reaching the screen.
The fix: List<Object?> get props => [query, onlyInStock];; every field that affects the UI belongs in props
Why it is silent: no error and no log: an equal state means "nothing changed", and BlocSelector, buildWhen or context.select would filter it the same way
Works once, then freezes: the very first emit of a fresh cubit is let through even when equal; every later one is dropped, so the switch appears to work once and then freezes
How it happens: a field added months after the class was written, with props forgotten; freezed derives == from the constructor, so it cannot drift
Catch it in a test: expect(state.copyWith(onlyInStock: true), isNot(state)), or a blocTest whose expect lists the missing emission
Every level has to list every field, so a nested update becomes a chain of copyWith calls where one forgotten field silently keeps its old value, and passing null cannot clear anything. freezed generates ==, hashCode and a deep copyWith that get both right.
Nesting: changing a city means state.copyWith(user: state.user.copyWith(address: ...)), and each hand-written level is one more place to drop a field
The null trap: a hand-written body reads middleName ?? this.middleName, so copyWith(middleName: null) means "unchanged"; freezed uses a sentinel, so null really clears the field
Deep copy: nested freezed types get profile.copyWith.address(city: 'Almaty')
The price: build_runner, a generated part file per model and a codegen step in CI; for three small classes Equatable plus a hand-written copyWith is fine
Or flatten: a state four levels deep often wants normalizing into maps keyed by id
load() awaits the network and then calls emit, but by the time the response arrives BlocProvider may have closed the cubit because the screen was popped. emit on a closed cubit throws a StateError, and since nobody awaits load() the error escapes as an unhandled async exception.
Why: await hands control back, and the cubit's lifetime is tied to the screen, not to the request; after close() every emit throws
The fix: check isClosed after every await before emitting: if (isClosed) return;. In bloc, event handlers registered with on<E> already ignore emits after close, so this mostly bites cubits
Better: also stop the work itself: keep the StreamSubscription or cancel token and cancel it in the overridden close(), so a closed screen does not keep hitting the network
Catch it: a test that calls load(), then close(), then completes the fake API, and expects no error
Derived values are computed from the state, never stored, and a screen reads the state it shows instead of copying it. A stored total goes stale the moment a promo code is applied, and a copied _name stays stale after an edit.
Copies: a screen that reads the profile into a String _name field in initState holds a snapshot forever; read it in build through watch, a StreamBuilder or a selector instead
Derived values are getters: double get total => subtotal - discount; cannot go stale; a stored total field has to be recomputed by every writer, and one of them will forget
**Keep derived values off disk and out of ==: a serialized stale total comes back after a restart; if a computation is expensive, cache it in the holder, keyed by its input, and only after measuring
Drafts are not copies**: the text in a field while the user types is a draft; it becomes the truth when the user saves
When they change together or must stay consistent with each other. Separate notifiers are updated one at a time, so listeners run between the writes and see a combination that is not a legal state.
Torn reads: each .value = notifies synchronously; a listener on filter that starts a fetch still reads page == 4 and loads page 4 of the new filter. Put an await between the writes and a frame paints the mixture too
Invariants need a home: "the selection only holds visible items" spans two values; in one class it is a method, across notifiers it gets copied into whichever widget noticed
Rebuild cost is a read-side problem: if one big object rebuilds too much, narrow the read with context.select or BlocSelector instead of splitting the model
Do split on lifetime or write rate: a scroll offset written 60 times a second, or a session that outlives the screen, belongs in its own holder
So each entity is stored once and every list holds only ids. An edit then touches one entry and every screen sees it, instead of updating copies of the same post in the feed, the profile and the search results, and missing one.
The shape: Map<PostId, Post> posts plus List<PostId> feed and List<PostId> saved; a row looks up posts[id] when it builds
One write per change: liking a post replaces posts[id]; with nested copies you search every list, and a forgotten one shows the old like count
Narrow rebuilds: a row selects its own state.posts[id] and rebuilds only when that post changes
The cost: an extra lookup, ids that point at nothing after a delete, and code that is harder to read; remove the id from every list when you delete the entity
When nesting is fine: data shown in one place, read-only, or replaced as a whole on every fetch; normalize when the same entity appears and changes in several places
Keep one draft object owned by the whole flow, with nullable, unvalidated fields, and turn it into the validated domain object once, on submit. The flow's route creates the draft and disposes it, so backing out needs no cleanup.
Two types: SignupDraft has String? email and friends; Signup has non-nullable, validated fields, and one toSignup() either builds it or reports what is missing
Why split them: otherwise the domain model keeps nullable fields forever just to fit the wizard, and every later consumer has to null-check them
Owned by the flow, not by a step: each step edits the shared draft; a step that owns its own data loses it when the user goes back
Scoped to the route: create the holder in the flow's route, for example a BlocProvider around the stepper; as a singleton, the next run opens pre-filled with the abandoned one
Commit once: call the repository after the last step and clear the draft on success; saving each step to the server turns every abandoned flow into an orphan record, unless the product explicitly wants "continue later"
Apply the change to the state at once, mark the item as pending, send the request, and on failure undo exactly that change and tell the user. The pending mark lets the UI and the rollback know which change is still unconfirmed.
Status per item, not per screen: with two saves in flight one isSaving flag cannot say which one failed; a Sync.pending | synced | failed field on the row can
Undo only your change: revert the field you changed on that row, not a snapshot of the whole state; restoring a snapshot also wipes everything the user did in the meantime
Errors are one-shot: show "Could not save" from a BlocListener when a row turns failed, not from a field that build reads on every rebuild
Pick candidates: likes, toggles and reordering are fine; payments, and anything whose result the server creates (an id, a final price), are not
Fast repeated taps: make sure a stale reply cannot overwrite a newer tap: tag requests with an id, or queue them with sequential() from bloc_concurrency
Both taps read state before either reply arrives, so both send true and the star stays on after two taps. Emit the new value before the request and either run toggles one at a time or tag each request with an id so a superseded reply (or its rollback) is ignored.
**await serializes nothing: the two handler bodies interleave, and each holds its own snapshot of the old state
Emitting first fixes the reads, not the order**: with emit(next) before the call both taps read fresh state, but the requests can still finish or arrive out of order, and a failed older request rolls back over the newer tap
Sequence token: emit the optimistic value first, bump an int _latest per tap, keep the local copy, and after the await return if it changed, including skipping the rollback; replies for superseded taps are dropped
Or queue the writes: a Bloc handler with sequential() from bloc_concurrency runs one tap at a time; droppable() ignores taps while a save runs, restartable() abandons the older handler
The server has the same race: two requests can arrive there out of order too; send a version or an If-Match ETag so the late write is rejected
Always: check isClosed (or mounted in a widget) after every await
Give a holder the shortest lifetime that all its readers share, and let whoever creates it dispose it. A holder created above the route keeps the last visit's filters, keeps old subscriptions running, and lets a late response from a closed screen overwrite the new one.
Name the event that should erase the state: screen closed means route scope, sign-out means session scope, process death means app scope. That event is the scope
Route scope: create the holder in the route, with BlocProvider(create: ...), a scoped ChangeNotifierProvider or a Riverpod autoDispose provider, so popping the route disposes it
Symptoms of living too long: a reopened screen shows stale filters; listen subscriptions pile up so a callback runs N times; a future from the first visit completes and overwrites the second
Session scope: put per-user holders under a subtree keyed by the user id (KeyedSubtree(key: ValueKey(userId))); signing out swaps the key and disposes them all
Too short is a bug too: a holder created in build, or inside a tab that is torn down, loses its state on every rebuild or tab switch; hoist it one level and write down why
Set restorationScopeId on MaterialApp, mix RestorationMixin into a State and register Restorable* properties in restoreState; the OS keeps them and hands them back after process death. Restore where the user was and what they typed, re-fetch server data, never restore credentials.
How it works
Registered values go into a RestorationBucket that the engine hands to the OS (Android's saved instance state, iOS state restoration). The bucket is small: store ids and inputs, not lists
Types: Restorable* wrappers (RestorableInt, RestorableString, RestorableTextEditingController, ...); subclass RestorableValue<T> for your own type
This is not a rebuild: the isolate is new: initState runs again and every singleton is created from scratch
What to restore
Restore: the route stack, form input, the selected tab, the ids of what was open
Re-fetch: anything the server owns: load the order again by its restored id
Rule of thumb: restore what the user typed or chose; re-fetch what the server could have changed in the meantime
Never: tokens or an "authenticated" flag, a "payment in progress" effect, derived values
Testing it
On Android enable "Don't keep activities", or background the app and run adb shell am kill <package>; pressing Back proves nothing. In widget tests use tester.restartAndRestore()
Make each step a subtype of a sealed state class, each input a subtype of a sealed event class, and write one synchronous next(state, event) that returns the new state. Illegal combinations cannot be written, and the whole flow is testable as a table with no widgets, network or clock.
The shape
States carry their data: Idle, Submitting, AwaitingConfirmation(paymentId), Paid(receipt), Failed(reason): no receipt screen without a receipt
Events are facts: Submitted, Accepted(paymentId), Confirmed(receipt), Rejected(reason), Retried
Switch on the pair: switch ((state, event)) lists the legal transitions, and _ => state turns everything else into a no-op instead of a crash
Why pure
IO stays outside: the cubit calls the API and feeds the result back as Accepted or Rejected; a reply for a cancelled flow simply falls into _ => state
Idempotent by construction: a duplicate Confirmed from a retried webhook and a push leaves Paid as it is
Testing: one parameterised test over (state, event, expected) pins the whole machine; inject the clock and the id generator if a state needs them
The cost
More types up front, which is overkill for a screen with two states. It pays off where money, auth or sync can otherwise land in an illegal combination
Save a version number with the payload, migrate step by step from any old version to the current one, and if anything fails to parse, delete the stored data and start from the initial state instead of crashing.
An envelope: persist {"v": 3, "data": {...}}; without the number you have to guess the shape from its keys
A chain of small migrations: 1 → 2, 2 → 3, each a pure map-to-map function; users skip releases, so the chain must work from any starting version
Persist the domain model, not the UI state: isRefreshing and error messages change every sprint and do not belong on disk
The load path: no data is a first launch, not an error; a version newer than the code (a rollback, a restored backup) is discarded; then migrate and decode inside one try
Delete before returning the fallback: leaving the bad data in place means the next launch fails the same way, and no restart fixes it
A fixture per shipped version: the old writer is gone from the code, so saved JSON files from each release are the only proof the chain works. hydrated_bloc's fromJson needs the same discipline