In my early days with mobile dev, I used to think asynchronous operations had only 3 states => Loading || Success || Failure
It seemed simple enough until I started working on complex projects, then the edge cases started appearing.
What happens before the operation even starts?
That isn't loading, success, or failure. And it shouldn't be confused with a successful request that simply returned an empty list. “Nothing has happened yet” and “the request succeeded with no data” are two different states.
What about pagination?
Page 1 succeeds and I have 20 items, then I request page 2.
If I switch to a plain loading state, I could end up replacing those 20 items with a loading indicator.
And what happens if page 3 succeeds but page 4 fails? I shouldn't lose everything I already loaded just because the latest request failed. I should retain the existing data and communicate the failure.
Then there's initialization from existing data.
Screen A fetches an item with isAddedToCart: true. I navigate to Screen B, which hasn't performed any operation yet, but already needs that value to initialize its UI. Again, that's not loading, success, or failure, It is actually an initial state.
Late last year, I came across someone discussing Dart 3 sealed classes and how they could be used for state management. I explored the idea and remodelled my asynchronous operation handling around four states:
Initial || Loading || Success || Failure, with each state carrying the information relevant to it:
=> Initial can carry initial data. This is useful for initializations.
=> Loading can retain current data while an operation is running, so the UI doesn't have to go blank (for pagination cases).
=> Success can carry the newly returned data and an optional success message. With pagination, that new data can be combined with the existing data rather than replacing it.
=> Failure always carries a failure message because every failure should communicate why the operation failed. It can also retain the previous data. If this previous data exists, the UI can continue displaying it even after the failure.
Aside a clear distinction between “nothing has happened yet” and “the operation succeeded but returned no data”, this design allows me to robustly handle pagination, refreshes, retained data, initial values, search workflows, retries, optimistic updates, operation-specific failure/success handling, success feedback, failure edge cases and a whole lot more...
In my next post, I'll talk about how this approach helped me eliminate a lot of repetitive API-call boilerplate when working with BLoC, and how it perfectly replaces Riverpod's AsyncLoading, AsyncData, and AsyncError with more flexibility.