Stackademic

Stackademic is a learning hub for programmers, devs, coders, and engineers. Our goal is to…

Follow publication

Handling Asynchronous Data in Flutter with Generic Classes

When fetching asynchronous data from the server to display on the screen, there are various aspects to consider. One of them is displaying the appropriate UI on the screen based on the state of the data. Typically, you need to consider the success, failure, and loading states of the data retrieval.

There are various approaches to define and manage data states, and I came across an interesting post on Reddit regarding this matter.

The post inquired about the effectiveness of different approaches in defining the state of data in the BLoC State management, and the author introduced two approaches.

  • Defining individual classes for each state.
  • Using Enums to define multiple states.

Various opinions were shared in the post, and since there is no definitive answer to the question, I couldn’t draw a conclusion. However, the advantages and disadvantages of each approach seemed clear.

I pondered on maintaining the advantages and compensating for the disadvantages, and found a hint in combining the concepts of these two approaches.

So, in this post, I will analyze the pros and cons of the two approaches for defining the data state mentioned on Reddit and introduce a new approach that preserves the advantages of the existing methods while addressing their shortcomings.

The Reddit post assumes the use of the BLoC state management library, but the methods discussed in this post are applicable to other state management libraries (such as Provider, Getx), so please keep that in mind.

1. Defining Individual Classes for Each State

Let’s analyze the first approach of defining individual classes for each state.

sealed class ProfileState {}  

class ProfileFetchedState extends ProfileState {
ProfileFetchedState({required this.info});

final User info;
}

class ProfileLoadingState extends ProfileState {}

class ProfileFailedState extends ProfileState {}

Firstly, this method involves defining a common abstract class named 'ProfileState,' and each state is represented by an individual class that inherits from this abstract class. This structure allows for the clear and distinct identification of the characteristics of each state.

return switch(state) {
ProfileSuccessState() => ProfileCard(),
ProfileLoadingState() => CircularProgressIndicator(),
ProfileFailedState() => ErrorIndicator(),
};

Furthermore, by declaring the common state abstract class as a sealed class, you can utilize a pattern matching syntax to intuitively branch and return UI based on the state type.

However, this approach comes with the drawback of introducing a lot of boilerplate code. You need to define each class separately for each state, resulting in a need for more code than necessary. Even in cases where the structure of states is similar or straightforward, there is an inconvenience of having to write each class individually.

2. Using Enumerations to Define Data States

The second approach involves utilizing an Enum within a single class to represent each state.

enum ProfileState { fetched, loading, failed }

class Profile {
final User? info;
final ProfileState state;

ProfileInfo({required this.userInfo, required this.state});
}

In this method, each state is directly expressed within a single class using an Enum, eliminating the need for separate classes. This enhances code conciseness and flexibility.

While the advantage lies in defining states without boilerplate code, this structure may potentially lead to a null hell. For instance, in the provided example of the Profile class, the 'user' field is declared as nullable. The reason is that the value of this field may not exist in specific states. If loading or fetching data fails, the user field will not be initialized

Profile data;  

switch (data.state) {
case ProfileState.fetched:
return ProfileCard(data.info!); // ❗Null check is required here
case ProfileState.loading:
return CircularProgressIndicator();
case ProfileState.eror:
return ErrorIndicator();
}

Therefore, even in states where data is successfully loaded, there is always the inconvenience of having to perform a null check.

3. Defining Data States Using a Generic Class

The two approaches introduced earlier each have their own advantages and disadvantages. Is there a way to address these drawbacks while preserving the benefits of both methods?

As a solution to this, I propose using a common generic class. This method can be seen as a combination of the concepts from the two approaches.

Firstly, we need to create some basic modules, so let’s break it down step by step.

Common Generic Class

enum DataState {  
fetched,
loading,
failed;
}

sealed class Ds<T> {
Ds({required this.state, this.error});

T? valueOrNull;
Object? error;
DataState state;

T get value => valueOrNull!;
}

Firstly, we create an Enum representing the data states and a generic abstract class named Ds(Data State). This abstract class accepts the data value and the data state (enum) in a generic form and also includes error information. Here are the details:

  • T? valueOrNull: A variable representing the data of generic type T or null. If the data is not null, the value method is defined to return the value.
  • Object? error: A variable representing error information. This variable contains details about the encountered error and can be null.
  • DataState state: A variable representing the state of the data. It takes one of the values from the DataState enum: DataState.fetched for successful data retrieval, DataState.loading for loading, and DataState.failed for failure.
  • T get value => valueOrNull!: A method that returns the value if the data is not null.

State Classes Inheriting the Generic Class

Next, let’s create subclassses that inherit from the previously implemented Ds<T> abstract class to represent each data state.

// Data state class for successful data retrieval
class Fetched<T> extends Ds<T> {
final T data;

Fetched(this.data) : super(state: DataState.fetched, valueOrNull: data);
}

// Data state class for loading
class Loading<T> extends Ds<T> {
Loading() : super(state: DataState.loading);
}

// Data state class for failed data retrieval
class Failed<T> extends Ds<T> {
final Object error;

Failed(this.error) : super(state: DataState.failed, error: error);
}

In each class constructor, we use the super keyword to initialize the state field of the superclass with an appropriate enum value representing the data state. Additionally, we conditionally initialize the valueOrNull and error member variables of the superclass as needed.

For example, the Fetched<T> class initializes the valueOrNull field with the passed data value when data is successfully retrieved. In contrast, the Loading<T> and Failed<T> classes do not receive data values, so there is no need for a separate initialization of the valueOrNull field. The Failed<T> class is designed to receive and initialize an error object of type Object.

If you find the code a bit familiar.

sealed class ProfileState {}  

class ProfileFetchedState extends ProfileState {
ProfileFetchedState({required this.info});

final User info;
}

class ProfileLoadingState extends ProfileState {}

class ProfileFailedState extends ProfileState {}

You may have noticed that it closely resembles the structure of the first approach introduced earlier. The difference is that, unlike before, the common state class is declared as a generic type, and each state class is mapped to an appropriate enum value.

Applying the Approach

Now that everything is set up, let’s define data states based on the implemented modules and see how to load data on the screen using an example.

  1. Initial Data Declaration
Ds<Profile> profileInfo = Loading(); // or Loading<Profile>();

Declare the profileInfo variable to hold profile information as type Ds<Profile> and assign the value Loading. This represents a state where no data has been loaded initially.

Note that since Loading is a subclass of Ds, it can be assigned to the profileInfo variable

2. Fetching Data

Future<void> fetchData() async {  
try {
profileInfo = Fetched(
User(
imgUrl: 'https://avatars.githubusercontent.com/u/75591730?v=4',
name: 'Ximya',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing ...',
),
);
log('Data successfully fetched');
} catch (e) {
profileInfo = Failed(e);
log('Data fetching failed. ${e}');
}
}

This is the part where data is fetched. If data is successfully retrieved, a User object with the corresponding profile information is created, wrapped in the Fetched class, and assigned to profileInfo. In case of a failure during data fetching, the error information is stored in the Failed state.

3. Returning Widgets Based on States

final profile = controller.profileInfo;  

return switch (profile) {
Fetched() => ProfileCard(profile.value),
Failed() => ErrorIndicator(profile.error),
Loading() => CircularProgressIndicator(),
};

Utilizing a pattern matching syntax, check the actual type of profile and return the appropriate widget for each state. If data is successfully loaded, use the .value syntax to access the data and pass it to the ProfileCard widget for rendering. In case of a failure, use the .error syntax to access the error data and pass it to the ErrorIndicator widget. When in the loading state, return a CircularProgressIndicator to indicate the loading status.

Advantages of Previous Approach

  • Intuitive branching of UI based on state using a pattern matching syntax
  • Easy identification of the characteristics of each state
  • Increased code conciseness and flexibility

Disadvantages of Previous Approach

  • No excessive boilerplate code
  • No tedious null checks

This implementation maintains the advantages of the existing two methods while addressing their drawbacks, allowing for straightforward handling of operations based on data states.

Branching Widgets in a Functional Programming Style

Although the current code already intuitively returns widgets based on the data state using a pattern matching syntax, there’s a way to return code in a more functional programming style. To achieve this, you can add two parts to the existing code.

1. Adding Getter Methods

enum DataState {  
fetched,
loading,
failed;

// Added code
bool get isFetched => this == DataState.fetched;
bool get isLoading => this == DataState.loading;
bool get isFailed => this == DataState.failed;
}

Add new getter methods to the DataState enum. These getter methods allow you to check whether the state is fetched, loading, or failed.

2. Adding the onState Method

sealed class Ds<T> {  
Ds({required this.state, this.error, this.valueOrNull});

T? valueOrNull;
DataState state;
T get value => valueOrNull!;

// Added code
R onState<R>({
required R Function(T data) fetched,
required R Function(Object error) failed,
required R Function() loading,
}) {
if (state.isFailed) {
return failed(error!);
} else if (state.isLoading) {
return loading();
} else {
return fetched(valueOrNull as T);
}
}
}

Next, add a new method called onState to the Ds<T> class. This higher-order function takes three functions for the generic type T based on the data state. Depending on the current data state, it calls the appropriate function and returns the result.

Applying the onState Method

final profile = controller.profileInfo;

return profile.onState(
fetched: (value) => ProfileCard(value),
failed: (e) => ErrorIndicator(e),
loading: () => CircularProgressIndicator(),
);

Now you can use the onState method to perform logic for branching widgets based on the state. Personally, it seems a bit more concise than the pattern matching syntax.

Conclusion

In this post, we explored a method of defining and managing data states using generic classes. As mentioned earlier, this is a problem that can have different answers depending on the perspective, so the approach I introduced is not necessarily the Best Practice. Nevertheless, I find the part about reducing code duplication and defining data states concisely appealing.

If you are curious about the example code covered in this post, you can check it out on my GitHub repository.

Thank you for reading!

Stackademic

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏
  • Follow us on Twitter(X), LinkedIn, and YouTube.
  • Visit Stackademic.com to find out more about how we are democratizing free programming education around the world.

Sign up to discover human stories that deepen your understanding of the world.

Published in Stackademic

Stackademic is a learning hub for programmers, devs, coders, and engineers. Our goal is to democratize free coding education for the world.

Responses (1)

Write a response

This article is a valuable resource for Flutter developers seeking to enhance their understanding of asynchronous data handling.

--