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.
- Read part 1: Project Objectives And Configuration
- Read part 2: Data model and Storage interface
- Read part 3: Dependencies and Errors in Functional Programming
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 fromfpdart
- How to use
riverpod
in combination withfpdart
- Use pattern matching to handle all possible states in the UI
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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:
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
:
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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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
:
sealed class GetAllEventState {
const GetAllEventState();
}
We can now extend GetAllEventState
to encode the success value containing List<EventEntity>
:
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 ππ§΅
We create a new get_all_event_error.dart
file, which we mark as part of
the previous get_all_event_state.dart
file:
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
:
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
:
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:
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 inget_all_event_error.dart
), while at the same time we reserve the error fromAsyncValue
for all cases that we did not handle usingfpdart
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.