Slaying a UI Antipattern with Flutter

Slaying a UI Antipattern with Flutter

·

6 min read

The Problem

This problem is presented in How Elm Slays a UI Antipattern by Kris Jenkins, and has inspired a host of implementations and articles in every language from Kotlin to Reason.

The problem commonly presented: when loading data from a remote data source, how do you capture its meta request state in a way that is rule-based, not convention-based?

Take the following simple model that uses a loading flag

var data = {
    "loading": true,
    "items": [],
};

Someone (probably me) will forget to check for this loading flag, resulting in a "No results" page being rendered. We must also manually track the data status by setting the correct data state & meta request state. What if you decide to handle a state where nothing has been requested from the data source, or an error has been returned? You might opt to use null for an initial state, which could eventually lead to maintenance issues when you forget to check for null.

var data = {
    "loading": true,
    "items": null
};

doSomething() {
  if(!data["loading"]) {
    print(items.length); // Whoopsie!
  }
}

Long experience will have taught you that setting a property to null may be correct, but it’s just asking for runtime exceptions

All of the problems with this approach stem from the fact that we are trying to merge two different types of state, the data state & meta request state. Managing state in this manner inherently leads developers to implement an awkward, at times error-prone, user experience. I myself have been both a victim of the software I use and a perpetrator in the software I've written.

The Solution

HTTP requests should have one of five states.

  • We haven't asked for the data yet
  • We've asked, but haven't got an answer yet
  • We got a response, and it was the data we wanted
  • We got a response, but it was an error
  • We got a response, but it was not the data we wanted

With Dart lacking Sealed Unions, and Sealed Classes there is no easy way to convey these states.

We can use enums

enum RemoteState { initial, loading, empty, success, error }

class RemoteModel {
    RemoteState state;
}

doSomething(RemoteModel model) {
  switch(model.state) {
      case RemoteState.initial:
            // Handles error
        break;
      case RemoteState.loading:
            // Handles error
        break;
      case RemoteState.success:
            // Handles error
        break;
      case RemoteState.error:
            // Handles error
        break;
          // Forgets to handle empty.  Cries tears of sadness.
  }
}

But in this scenario, if we aren't using a strict analyzer rule, we may never realize that that we've failed to handle all of our scenarios. We are also back to one of our original issues of managing separate concerns (data state vs. request state) in the same object, and we now have to figure out mapping for a more complex data structure.

We could solve this with plain classes.

abstract class RemoteState {}
class RemoteStateInitial extends RemoteState{}
class RemoteStateLoading extends RemoteState {}
class RemoteStateEmpty extends RemoteState {}
class RemoteStateSuccess extends RemoteState {}
class RemoteStateError extends RemoteState {}

doSomething(RemoteState state) {
  if(state is RemoteStateInitial) {
    // Handles initial
  }
  if(state is RemoteStateEmpty) {
    // Handles empty
  }
  if(state is RemoteStateSuccess) {
    // Handles success
  }
  // Forgets to handle error & loading.  Cries tears of sadness.
}

For each class we could define a more complex object with data state specific to the meta request state, but notice there is no one to police us (specifically me) to make sure that we handle every case.

Fortunately, there's a pretty good library out there that allows us to generate the code that would simulate the effects we desire: freezed. We can use freezed to give us those modern language features that we so desperately desire.

@freezed
abstract class RemoteState<T> with _$RemoteState<T> {
  const factory RemoteState.initial() = _Initial<T>;
  const factory RemoteState.loading() = _Loading<T>;
  const factory RemoteState.success(T value) = _Success<T>;
  const factory RemoteState.empty() = _Empty<T>;
  const factory RemoteState.error([String message]) = _Error<T>;
}

// A class far, far away...
class RemoteModel {}

// Now when we handle our data model, 
// we can properly represent the list of things 
// with methods that represent async request state
doSomething(RemoteState<RemoteModel> state) {
  counterState.when(
    initial: () => { /* Handle initial */ },
    empty: () => { /* Handle empty */ },
    success: () => { /* Handle success */ },
  )
}

Now, when we handle our data model, we can properly represent our state with methods that match meta request state. Above, you also might notice that we've accidentally left out some states, but don't you worry. RemoteState don't play that!

Screen Shot 2020-04-23 at 12.50.33 AM.png

The analyzer will inform us of the errors of our ways by default. We could even turn this warning into an error if we wanted to, enforcing hard rules over soft conventions. So we'll get that harsh taste of reality long before we start doing our happy dance!

RemoteState yelling at me

If you really want some joy, watch the original, maybe NSFW video

Personally, I love this pattern so much that I created a library for it. And mostly that is just because I'm too lazy to continue to copy and paste code from project to project. It is framework & state management library agnostic.

The Download

If you want to use this pattern, I have published it as a standalone package that you can use in your project. I use it on several projects with rxdart and graphql, and it has been tested with bloc and state_notifier & provider.

Here are the docs. Slay that UI!

Sup?! I’m Ryan Edge. I am a Software Engineer at Superformula and a semi-pro open source contributor. If you liked this article, be sure to follow and spread the love! Happy trails