How to use fpdart and riverpod in Flutter | Fpdart and Riverpod Functional Programming in Flutter

Sandro Maglione

Sandro Maglione

Mobile development

This is the fourth part of a new series in which we are going to learn how to build a safe, maintainable, and testable app in Flutter using fpdart and riverpod.

We will focus less on the implementation details, and more on good practices and abstractions that will helps us to build a flexible yet resilient app in Flutter.

As always, you can find the final Open Source project on Github:

In this article we are going to:

  • Create riverpod's providers
  • Learn why and how to use the ReaderTask type from fpdart
  • How to use riverpod in combination with fpdart
  • Use pattern matching to handle all possible states in the UI

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

Recap: ReaderTaskEither

In the previous article we learned how to handle errors and dependencies using fpdart.

We defined what a dependency is and how to recognize them.

We also learned how to organize errors using the Either type and pattern matching (sealed).

Finally, we introduced the ReaderTaskEither type, used to encode dependencies, errors, and success values all together (Reader for dependencies, Either for errors):

ReaderTaskEither<Dependencies, RequestError, Success> getAllEvent = ReaderTaskEither(/* TODO */);

In this post we are going to setup riverpod using riverpod_generator. We are then going to learn how to connect and use fpdart in combination with riverpod ๐Ÿ‘‡


Organize riverpod's providers: StorageService

In part 2 of this series we defined the StorageService class:

storage_service.dart
abstract class StorageService {
  Future<List<EventEntity>> get getAll;
  Future<EventEntity> put(String title);
}

StorageService defines the implementation of all the methods in the API of the app. For this reason, StorageService is the main dependency in ReaderTaskEither:

///              ๐Ÿ‘‡ Dependency
ReaderTaskEither<StorageService, Errors, Success> getAllEvent = ReaderTaskEither(/* TODO */);

We therefore need to access a valid implementation of StorageService to provide to all fpdart requests.

We are going to create a provider specific for StorageService:

storage_service_provider.dart
import 'package:fpdart_riverpod/services/storage_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
 
part 'storage_service_provider.g.dart';
 
@riverpod
StorageService storageService(StorageServiceRef ref) {
  /// Return concrete instance of [StorageService]
  throw UnimplementedError();
}

This code defines a storageService function that returns an instance of StorageService.

This code uses riverpod_generator to auto-generate a storageServiceProvider used to provide a valid StorageService instance.

Error handling with fpdart and riverpod

The second provider that we need is responsible to execute getAllEvent (ReaderTaskEither) and return the list of events to the UI.

We now need to define the return type for this provider. As mentioned in the last article in the series, we want to take advantage of sealed class and pattern matching to match all possible success and error values in the UI.

In fpdart we handle errors using the Either type. By using Either we can encode both errors and return values in one type.

Specifically, since the StorageService API returns Future, we would need to use TaskEither:

@riverpod
TaskEither<Errors, List<EventEntity>> eventList(EventListRef ref)

We learned what TaskEither is and how to use it in a previous article

The problem is that riverpod already has its own way of handling errors. In fact, a FutureProvider returns a value of type AsyncValue.

AsyncValue provides a loading and error state by default. Therefore, using Either in this context would be inconvenient, since this would duplicate the code to handle errors:

eventList.map(
  loading: (_) => ...,
  error: (error) => ..., // ๐Ÿ‘ˆ Error from `riverpod`'s `AsyncValue`
  data: (either) => either.match(
    (error) => switch(error) { ... }, // ๐Ÿ‘ˆ Pattern match error from `fpdart`'s `Either`
    (success) => switch(success) { ... }, // Pattern match success value from `fpdart`'s `Either`
  ),
)

What we want instead is to flatten this nested matching to one level, and use pattern matching on all possible states using a single switch:

eventList.map(
  loading: (_) => ...,
  error: (error) => ..., // ๐Ÿ‘ˆ Unexpected errors
  data: (data) => switch(data) { ... }, // ๐Ÿ‘ˆ One `switch` for all expected errors and success value
)

The solution is to avoid using Either and instead let riverpod catch any unexpected error using AsyncValue.

Therefore, instead of using ReaderTaskEither in fpdart we are going to use ReaderTask (without the Either part).

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

GetAllEventState: Success and errors pattern matching

We still need to define the return type for ReaderTask:

ReaderTask<StorageService, ReturnType> getAllEvent = ReaderTask(/* TODO */);

ReturnType should encode all possible errors and the success value in one sealed class. By doing this, we can take advantage of pattern matching in the UI by providing a different widget for all possible states.

By using sealed we will be required at compile-time to handle all possible states, which reduces the possibility of errors in the UI

We start by defining a sealed class used as return type in ReaderTask, called GetAllEventState:

get_all_event_state.dart
sealed class GetAllEventState {
  const GetAllEventState();
}

We can now extend GetAllEventState to encode the success value containing List<EventEntity>:

get_all_event_state.dart
import 'package:fpdart_riverpod/entities/event_entity.dart';
 
sealed class GetAllEventState {
  const GetAllEventState();
}
 
class SuccessGetAllEventState extends GetAllEventState {
  final List<EventEntity> eventEntity;
  const SuccessGetAllEventState(this.eventEntity);
}

part/part of for error states

In order to keep the code organized, we want to define the error states in a separate file.

Since sealed classes can only be extended in the same library, we need to use part/part of.

Dart 3 ๐Ÿ‘‰ `sealed` requires to define all subtypes *in the same library* What does it mean "same library" in dart? Turns out you can have multiple files be part of the same "library" This is how ๐Ÿ‘‡๐Ÿงต

Image
19
Reply

We create a new get_all_event_error.dart file, which we mark as part of the previous get_all_event_state.dart file:

get_all_event_error.dart
part of 'get_all_event_state.dart';
 
sealed class GetAllEventError extends GetAllEventState {
  const GetAllEventError();
}
 
class QueryGetAllEventError extends GetAllEventError {
  final Object object;
  final StackTrace stackTrace;
  const QueryGetAllEventError(this.object, this.stackTrace);
}

We also need to add part to get_all_event_state.dart:

get_all_event_state.dart
import 'package:fpdart_riverpod/entities/event_entity.dart';
 
part 'get_all_event_error.dart';
 
sealed class GetAllEventState {
  const GetAllEventState();
}
 
class SuccessGetAllEventState extends GetAllEventState {
  final List<EventEntity> eventEntity;
  const SuccessGetAllEventState(this.eventEntity);
}

By doing this, we now have a clear separation between success response (SuccessGetAllEventState inside get_all_event_state.dart) and errors (inside get_all_event_error.dart), while still taking advantage of sealed class and pattern matching:

ReaderTask<StorageService, GetAllEventState> getAllEvent = ReaderTask(/* TODO */);

fpdart with riverpod: eventListProvider

We can now connect all the pieces together!

We define a new eventList function that returns GetAllEventState.

eventList calls getAllEvent (ReaderTask) and runs it by providing a concrete instance of StorageService:

event_list_provider.dart
import 'package:fpdart_riverpod/datasources/get_all_event/get_all_event.dart';
import 'package:fpdart_riverpod/datasources/get_all_event/get_all_event_state.dart';
import 'package:fpdart_riverpod/providers/storage_service_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
 
part 'event_list_provider.g.dart';
 
@riverpod
Future<GetAllEventState> eventList(EventListRef ref) async {
  /// Get dependency from the `storageServiceProvider` we generated before
  final service = ref.watch(storageServiceProvider);
   
  /// Call `run` from `ReaderTask` by providing a valid `StorageService` instance
  return getAllEvent.run(service);
}

After running the build of riverpod_generator we have access to a new eventListProvider, which we are now going to use in our UI ๐Ÿ‘‡

Pattern matching UI

The final step is consuming the eventListProvider inside the UI.

We use ref.watch to listen to state changes, and then we use pattern matching on all possible states:

home_page.dart
class HomePage extends HookConsumerWidget {
  const HomePage({Key? key}) : super(key: key);
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final eventList = ref.watch(eventListProvider);
 
    return SafeArea(
      child: Scaffold(
        body: eventList.map(
          /// Loading state from [AsyncValue]
          loading: (_) => const Text('Loading...'),
 
          /// Error state from [AsyncValue]
          error: (error) => Text("Error: $error"),
 
          /// Success state from [AsyncValue], containing `value` of type [GetAllEventState]
          /// Pattern matching on all possible states (check at compile-time ๐Ÿš€)
          data: (data) => switch (data.value) {
            QueryGetAllEventError() => const Text("Empty"),
 
            SuccessGetAllEventState(eventEntity: final eventEntity) => Column(
                children: [
                  Text('${eventEntity.length} length'),
                  ...eventEntity.map(
                    (eventModel) => Card(
                      child: Text(eventModel.title),
                    ),
                  )
                ],
              )
          },
        ),
      ),
    );
  }
}

The error state from AsyncValue encodes unexpected errors.

We use fpdart to encode all expected errors (defined in get_all_event_error.dart), while at the same time we reserve the error from AsyncValue for all cases that we did not handle using fpdart


This is it for part 4!

We connected together fpdart and riverpod by using the ReaderTask type. We encoded all possible errors using sealed and pattern matching, and finally we defined the UI for all the possible states in our application.

As you may have noticed, we still did not implement neither StorageService nor the getAllEvent function using fpdart:

ReaderTask<StorageService, GetAllEventState> getAllEvent = ReaderTask(/* TODO */);

Since the app structure relies on abstractions, we are able to wire all the code together even without any concrete implementation.

This allows for easier testing and better maintenance, since all the logic is isolated in its own layer of abstraction.

In the next part we are finally going to implement getAllEvent and see the app in action ๐Ÿš€

If you want to stay up to date with the latest releases, you can subscribe to my newsletter here below ๐Ÿ‘‡

Thanks for reading.

๐Ÿ‘‹ใƒปInterested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.