Modelling App State
Irbisa · cheatsheetSeptember 13, 2026

Modelling App State

Immutability, freezed, sealed unions, equality, optimistic updates, state restoration

Middle Developer16 itemscompressed for a skim
  1. 01

    A teammate moved every value out of setState into one app-wide store "for consistency". What does that cost you?

    Easy

    Ephemeral 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
    …
  2. 02

    Your list widget sorts state.items in place before it builds. What breaks, and why didn't the compiler stop you?

    Easy

    final freezes the reference, not the contents, so sort() 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
    …
  3. 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?

    Medium

    Either stop nesting — flatten the state and key the parts by id — or let a generator write the copies, because hand-written copyWith chains 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'),
      ),
    );
    …
  4. 04

    Your state class implements ==, yet the screen rebuilds on every emit even when the list holds exactly the same items. Why?

    Medium

    List has 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
    …
  5. 05

    Your screen owns seven small notifiers, and now changing the filter must also reset the page and clear the selection. What breaks?

    Medium

    Nothing 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) {
    …
  6. 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?

    Medium

    Anything 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;
    …
  7. 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?

    Medium

    Order 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;
    …
  8. 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?

    Medium

    Apply 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;
    …
  9. 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?

    Medium

    Put 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. 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?

    Medium

    Because 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. 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?

    Medium

    In 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. 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?

    Hard

    Restore 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. 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?

    Hard

    Replace 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. 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?

    Hard

    The 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. 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?

    Hard

    With 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. 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?

    Hard

    Split 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),
    …