Build1 publisher3 min readPublished
The duplicate snackbar is a modelling error, and the fix belongs in the state layer
A Flutter post argues one-shot effects do not belong in persistent state, and that a synchronous state layer removes the need for a presentation-stream package.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The post describes the "Sticky State Dilemma": on authentication failure the state container emits an error and the UI shows a SnackBar; when the user rotates the phone, pulls down the notification shade, or types on the virtual keyboard, the widget tree rebuilds, the container is still holding AuthErrorState("Invalid password"), the UI listener fires again, and a duplicate snackbar appears.
- The post states that state management in Flutter is designed to model persistent truth over time, giving examples such as whether the user is logged in (AuthState.authenticated(user)), whether data is loading (TodoState.loading), and the cart total ($49.99), and that persistent state answers "What is the system's current condition?"
- The post describes UI presentation actions as ephemeral pulses: show a brief SnackBar toast, pop up an alert confirmation dialog, push a new route on the Navigator stack, vibrate the haptic motor; these answer "What just happened that requires a one-time reaction?"
- The post's comparison table says persistent state is survived by UI rebuilds, represented in signals, and backed by equality diffs, while an ephemeral side-effect is consumed once and gone, triggered by an event, and has zero domain state footprint.
- The first historical workaround shown is emitting a reset state immediately after an error: emit(AuthFailure(error)); emit(AuthInitial()); the post says this causes two separate microtask queue ticks and multiple widget rebuild cycles just to reset a transient flag.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A dev.to post on BlocSignal reopens a familiar Flutter failure: authentication fails, the container emits an error, the listener shows a SnackBar, and then the user rotates the phone, the tree rebuilds, the container is still holding `AuthErrorState("Invalid password")`, the listener fires again, and a second snackbar appears [1]. This matters less as a rendering annoyance than as a modelling result: the container is doing exactly what a container is for, which is surviving rebuilds and handing its current value to whoever asks [4], so the replay is the design working, not the design breaking [15].
The post's taxonomy is the useful part. Persistent state answers what the system's condition is now - logged in, loading, cart total [2]. Presentation actions are pulses that answer what just happened and needs a single reaction: a toast, a confirmation dialog, a route push, a haptic buzz [3]. Persistent state is compared by equality and outlives rebuilds; an effect is consumed once, is triggered by an event, and should leave no domain footprint [4]. Once you write those two columns down, the duplicate snackbar stops being mysterious.
Three historical workarounds are enumerated [13]. Emit the failure and then immediately emit `AuthInitial()`, which the post says costs two microtask ticks and multiple rebuild cycles just to clear a transient flag [5]. Or carry `errorSnackbarMessage` and a `hasShownSnackbar` bool inside the state class, which the post calls manual bookkeeping that clutters domain types with imperative UI tracking [6]. Or adopt LeanCode's `package:bloc_presentation`, which bolts a second broadcast StreamController onto a Bloc so `emitPresentation(MyEvent())` runs independently of `emit(state)` [7]. Two of the three mutate domain state to carry a UI event; the third moves the boundary out of your codebase and into a dependency [16]. The post credits `bloc_presentation` with solving the problem for classic BLoC while objecting to the versioning churn, boilerplate and dependency overhead of maintaining wrapper packages in a monorepo [8].
The claim underneath BlocSignal's alternative is a timing claim: `emit(newState)` updates the signal and settles dependencies in the same frame, rather than queueing on microtask-based streams as classic BLoC does [9]. If that holds, the recommended pattern needs no channel at all: await `cubit.signIn(...)`, guard with `if (!context.mounted) return`, then pattern-match on `cubit.stateValue` to push a route on success or show a snackbar on failure [10]. The post's argument for it is that `stateValue` is current the instant the call returns, that rotations and unrelated rebuilds never re-run a button handler, and that no listeners, streams or consumable wrapper classes are required [11], with zero additional package dependencies [14].
Read the sample carefully and the boundary has moved to the call site rather than disappeared: the branch lives in the widget callback and is triggered by the interaction, not by observing state [17]. That is fine for taps and useless for effects the domain raises on its own, which the post acknowledges - a WebSocket disconnect, a finished background sync, or an in-progress migration off `bloc_presentation` - and it offers a compatible drop-in for those cases, at which point the supplied excerpt cuts off [12].
Worth watching: whether the compatible path for autonomous effects is genuinely drop-in, since that is where the second channel reappears inside the library instead of beside it [12]; and whether same-frame settling holds under real load, because every claimed property in the pattern rests on it [9][11]. All of the above comes from one post about its own library, unverified here.