Pure Functional app in Flutter, is it possible? Of course! Today we are going to explore a Pure Functional app in Flutter using fpdart. I am going to explain step by step how to use the power of Functional Programming and fpdart to develop a Flutter application.
You will learn many Functional Programming types from fpdart used to make your application more composable. The app will use riverpod for state management, http to perform remote requests, freezed, and flutter_hooks. And of course fpdart for Functional Programming!
Do you want to learn more about fpdart and Functional Programming? Check out the previous two articles in which I explain the basics of fpdart and Functional Programming:
- Fpdart, Functional Programming in Dart and Flutter
- How to use fpdart Functional Programming in your Dart and Flutter app
A Pokemon app to explain Functional Programming!
That's right! The example you are going to see today is a Flutter app that lets you search and view your favorite Pokemon!
The app has a single screen. The screen shows the image of a pokemon fetched from the pokeAPI. It also has a TextField
and a button that allows you to search and view a Pokemon given his id
in the Pokedex.
A screenshot of the final application. You can view your favorite Pokemon and search for a new one!
The application is simple, but it has all the main components that you will find in a more complex app:
- State management using riverpod to perform requests to the API and listen for the response
- JSON serialization and deserialization to convert the response from the API to a dart object
- freezed (Sum types) to display a different UI based on the status of the API request
- flutter_hooks to reduce boilerplate code and improve readability
- http to perform remote requests
Create the app and import the packages
We start from the beginning. Open a directory that you want and create a new Flutter app using the create
command:
flutter create pokeapi_functional
You will now have a new pokeapi_functional
folder that contains the app project.
We then need to import the required packages used in the app. Open the pubspec.yaml
file inside the pokeapi_functional
folder and copy-paste the code below:
name: pokeapi_functional
description: Functional Programming using fpdart. Fetch and display pokemon from pokeApi.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=2.12.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
http: ^0.13.3
hooks_riverpod: ^1.0.0-dev.4
flutter_hooks: ^0.18.0
freezed: ^0.14.2
fpdart: ^0.0.7
dev_dependencies:
flutter_test:
sdk: flutter
freezed_annotation: ^0.14.2
build_runner: ^2.0.5
flutter:
uses-material-design: true
We import all the packages mentioned in the previous section, as well as build_runner and freezed_annotation for code generation.
We are now ready to start working on the app!
Pokemon
and Sprite
models with JSON serialization
We start by defining the model classes. These are simple dart classes used to convert the JSON API response from pokeAPI to a dart object used inside the app.
We are going to request the Pokemon information from this endpoint in the pokeAPI.
{
"id": 12,
"name": "butterfree",
"base_experience": 178,
"height": 11,
"is_default": true,
"order": 16,
"weight": 320,
"sprites": { /* ... */ },
/* ... */
For the purpose of this example, we are going to store the id
, name
, weight
, height
, and sprites
of the Pokemon.
We create a new models
folder inside lib
that will contain our model classes. Inside the folder, we create a pokemon.dart
file with the following code:
import 'package:pokeapi_functional/models/sprite.dart';
class Pokemon {
final int id;
final String name;
final int height;
final int weight;
final Sprite sprites;
const Pokemon({
required this.id,
required this.name,
required this.height,
required this.weight,
required this.sprites,
});
}
We also create a sprite.dart
file with the following code:
class Sprite {
final String front_default;
const Sprite({
required this.front_default,
});
}
The Sprite
class contains the link to the image of the pokemon, while the Pokemon
class contains all the information about the pokemon. As you can see, the Pokemon
class contains a sprite
field of type Sprite
.
JSON deserialization
We need a method to convert the JSON response from the API (a plain String
) to a Pokemon
. We define a fromJson
method that tries to map the response to a valid Pokemon
object (we do the same also for the Sprite
class):
import 'package:pokeapi_functional/models/sprite.dart';
/// Pokemon information, with method to deserialize json
class Pokemon {
final int id;
final String name;
final int height;
final int weight;
final Sprite sprites;
const Pokemon({
required this.id,
required this.name,
required this.height,
required this.weight,
required this.sprites,
});
static Pokemon fromJson(Map<String, dynamic> json) {
return Pokemon(
id: json['id'] as int,
name: json['name'] as String,
weight: json['weight'] as int,
height: json['height'] as int,
sprites: Sprite.fromJson(json['sprites'] as Map<String, dynamic>),
);
}
}
/// Pokemon sprite image, with method to deserialize json
class Sprite {
final String front_default;
const Sprite({
required this.front_default,
});
static Sprite fromJson(Map<String, dynamic> json) {
return Sprite(
front_default: json['front_default'] as String,
);
}
}
Pure Functional API request
The next step is defining the methods to validate the user input and send the request to the API to fetch the Pokemon. This is where Functional Programming comes into play!
The principle underlining our code is composability. We want to have small pure functions, each performing one simple operation. Then we are going to compose these functions one by one to form the complete request.
Create a new folder for the API request
We create a new api
folder inside lib
, and inside it a fetch_pokemon.dart
file.
We will not create any class, since we want our code to be completely functional. Instead, we are going to define a series of private functions (by adding the _
suffix to them) that cannot be accessed from any other file. These functions will be composed together to form the final function used to perform the API request. This will be the only function that we export from the file (it will be a public function).
Parse the user input in a Functional way
The user inserts the pokemon id using a TextField
. Since the TextField
value is a String
while the pokemon id is an int
, we need to parse the user input to the correct format before making the request. We use the parse
method of dart.
The parse
method can fail and throw a FormatException
if the given String
is not a valid int
. We do not use try/catch
or throw
at all in Functional Programming. These constructs are not composable! We are going to use fpdart instead.
parse
is a synchronous method that can fail:
- For synchronous operations, we use the
IO
type of fpdart, which allows us to easily compose multiple methods - For operations that can fail, we use the
Either
type of fpdart
Therefore, we need a way to combine IO
and Either
together. fpdart provides a type called IOEither
exactly for this usecase!
IOEither
: a synchronous method that can fail
The first step when working with Functional Programming is defining the signature of the functions, which means writing the input and output types.
The parsing function returns an IOEither
. The IOEither
type requires two generic types (same as Either
), the first one is the type of data to return when the function fails (also called Left
), while the second is the return type when the function is successful (also called Right
).
In our example, the error type will be a simple String
, while the valid return type is an int
. The signature of the function therefore is as follows:
/// Parse [String] to [int] in a functional way using [IOEither].
IOEither<String, int> _parseStringToInt(String str);
Try/Catch using IOEither
in Functional Programming
We want to catch possible errors when calling the parse
function. We use the IOEither.tryCatch
constructor.
/// Parse [String] to [int] in a functional way using [IOEither].
IOEither<String, int> _parseStringToInt(String str) => IOEither.tryCatch(
() => int.parse(str),
(_, __) => 'Cannot convert input to valid pokemon id (it must be a number)!',
);
We define the first function parameter to execute as if it can never fail (int.parse
). In case of errors, the constructor will call the second function that returns the error message.
Validate the pokemon id, the pokemon must exist!
Not all int
are valid pokemon! There are 898 pokemon known today. Therefore, the pokemon id must be between 1 and 898.
We define another function to validate the int
value that we parsed in the previous step. The signature is similar. The function is synchronous (IO
) and can fail (Either
), IOEither
!
/// Validate the pokemon id inserted by the user:
/// 1. Parse [String] from the user to [int]
/// 2. Check pokemon id in valid range
IOEither<String, int> _validateUserPokemonId(String pokemonId);
flatMap
: chain functions together
We need to chain the previous function so that we can parse the input String
to int
.
In an imperative world, we would need to write a long series of if-else. Using Functional Programming is easier. We chain an IOEither
with another using the flatMap
method:
/// Validate the pokemon id inserted by the user:
/// 1. Parse [String] from the user to [int]
/// 2. Check pokemon id in valid range
///
/// Chain (1) and (2) using `flatMap`.
IOEither<String, int> _validateUserPokemonId(String pokemonId) =>
_parseStringToInt(pokemonId).flatMap(
(intPokemonId) => /* ... */
);
flatMap
will execute _parseStringToInt
. If _parseStringToInt
is successful, flatMap
will call the given function, passing the intPokemonId
. If _parseStringToInt
fails instead, no function will be called and the String
error we previously defined will be returned.
IOEither
from an int
using IOEither.fromPredicate
Inside the flatMap
method, we have access to the valid Pokemon id as int
(intPokemonId
). Since our function returns an IOEither
, we need to build an IOEither
from an int
passing a validation function. We use IOEither.fromPredicate
!
/// Validate the pokemon id inserted by the user:
/// 1. Parse [String] from the user to [int]
/// 2. Check pokemon id in valid range
///
/// Chain (1) and (2) using `flatMap`.
IOEither<String, int> _validateUserPokemonId(String pokemonId) =>
_parseStringToInt(pokemonId).flatMap(
(intPokemonId) => IOEither.fromPredicate(
intPokemonId,
(id) =>
id >= Constants.minimumPokemonId &&
id <= Constants.maximumPokemonId,
(id) =>
'Invalid pokemon id $id: the id must be between ${Constants.minimumPokemonId} and ${Constants.maximumPokemonId + 1}!',
),
);
IOEither.fromPredicate
takes the value to validate (the Pokemon id), a validation function (id between 1 and 898), and an error String
in case the given int
value is not valid. Simple and powerful!
HTTP request to get the Pokemon: TaskEither
Finally, the last step is implementing the function that performs the API request and returns a Pokemon
.
If we use the same logic as before, we can say that:
- The function performs an asynchronous operation
- The operation can fail
For asynchronous operations, we use the Task
type. For operations that can fail, we use the Either
type. Therefore, the return type of the function will be TaskEither
!
/// Make HTTP request to fetch pokemon information from the pokeAPI
/// using [TaskEither] to perform an async request in a composable way.
TaskEither<String, Pokemon> fetchPokemon(int pokemonId);
Remote request using the http package
We are going to use the tryCatch
constructor of TaskEither
to catch possible errors. Using the http package, we perform a simple request and then we convert the JSON String
to a Pokemon
:
/// Make HTTP request to fetch pokemon information from the pokeAPI
/// using [TaskEither] to perform an async request in a composable way.
TaskEither<String, Pokemon> fetchPokemon(int pokemonId) => TaskEither.tryCatch(
() async {
final url = Uri.parse(Constants.requestAPIUrl(pokemonId));
final response = await http.get(url);
return Pokemon.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
},
(error, __) => 'Unknown error: $error',
);
Composing IOEither
and TaskEither
: flatMapTask
The final function that takes the user input String
and returns a Pokemon
is extremely simple!
/// Try to parse the user input from [String] to [int] using [IOEither].
/// We use [IOEither] since the `parse` method is **synchronous** (no need of [Future]).
///
/// Then check that the pokemon id is in the valid range.
///
/// If the validation is successful, then fetch the pokemon information from the [int] id.
///
/// All the functions are simply chained together following the principle of composability.
TaskEither<String, Pokemon> fetchPokemonFromUserInput(String pokemonId) =>
_validateUserPokemonId(pokemonId).flatMapTask(fetchPokemon);
That's the power of Functional Programming! All our functions are composable. We can use flatMapTask
to chain the _validateUserPokemonId
function, that returns IOEither
, with the fetchPokemon
function that returns TaskEither
.
RequestStatus
: using freezed to handle all the app states
When we make a remote request, our app can be in at least 4 states:
- Initial state: When the app just launched and it is waiting for any input or function call
- Loading state: State when the app is waiting for the API request to complete
- Error state: State when the API request failed with an error
- Succes state: State when the Pokemon is successfully fetched
We want to force our app to define what to display for each of these states. Therefore, we are going to use freezed to define these 4 states as follows:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
import 'package:pokeapi_functional/models/pokemon.dart';
part 'request_status.freezed.dart';
/// Different request status when making API request.
///
/// Each status maps to a different UI.
@freezed
class RequestStatus with _$RequestStatus {
const factory RequestStatus.initial() = InitialRequestStatus;
const factory RequestStatus.loading() = LoadingRequestStatus;
const factory RequestStatus.error(String string) = ErrorRequestStatus;
const factory RequestStatus.success(Pokemon pokemon) = SuccessRequestStatus;
}
Remember to run the build command to generate the class:
flutter packages pub run build_runner build
State management using riverpod
We are going to use riverpod to manage the state of the app. As we said previously, the app can be in 4 states defined by the RequestStatus
freezed class:
/// Manage the [Pokemon] state using [Either] ([TaskEither]) to handle possible errors.
///
/// Each [RequestStatus] changes the UI displayed to the user.
class PokemonState extends StateNotifier<RequestStatus> {
PokemonState() : super(const RequestStatus.initial());
}
Our provider will have two methods:
- The first method takes an
int
and retrieves the initial randomPokemon
to display at the start of the app - The second method takes the user's input
String
and tries to fetch thePokemon
from the API
We use both the fetchPokemon
and fetchPokemonFromUserInput
functions we defined in our api
folder.
/// Manage the [Pokemon] state using [Either] ([TaskEither]) to handle possible errors.
///
/// Each [RequestStatus] changes the UI displayed to the user.
class PokemonState extends StateNotifier<RequestStatus> {
PokemonState() : super(const RequestStatus.initial());
/// Initial request, fetch random pokemon passing the pokemon id.
Future<Unit> fetchRandom() async => _pokemonRequest(
() => fetchPokemon(
randomInt(
Constants.minimumPokemonId,
Constants.maximumPokemonId + 1,
).run(),
),
);
/// User request, try to convert user input to [int] and then
/// request the pokemon if successful.
Future<Unit> fetch(String pokemonId) async => _pokemonRequest(
() => fetchPokemonFromUserInput(pokemonId),
);
}
Run a TaskEither
and match
the result
Both functions above use a method called _pokemonRequest
. This method performs the API request by calling the run
method of TaskEither
. It then uses the match
method to return an error state in case of errors, or a success state in case the request is valid:
/// Generic private method to perform request and update the state.
Future<Unit> _pokemonRequest(
TaskEither<String, Pokemon> Function() request,
) async {
state = RequestStatus.loading();
final pokemon = request();
state = (await pokemon.run()).match(
(error) => RequestStatus.error(error),
(pokemon) => RequestStatus.success(pokemon),
);
return unit;
}
Finally, we define the actual StateNotifierProvider
used inside the display widget:
import 'package:fpdart/fpdart.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pokeapi_functional/api/fetch_pokemon.dart';
import 'package:pokeapi_functional/constants/constants.dart';
import 'package:pokeapi_functional/models/pokemon.dart';
import 'package:pokeapi_functional/unions/request_status.dart';
/// Manage the [Pokemon] state using [Either] ([TaskEither]) to handle possible errors.
///
/// Each [RequestStatus] changes the UI displayed to the user.
class PokemonState extends StateNotifier<RequestStatus> {
PokemonState() : super(const RequestStatus.initial());
/// Initial request, fetch random pokemon passing the pokemon id.
Future<Unit> fetchRandom() async => _pokemonRequest(
() => fetchPokemon(
randomInt(
Constants.minimumPokemonId,
Constants.maximumPokemonId + 1,
).run(),
),
);
/// User request, try to convert user input to [int] and then
/// request the pokemon if successful.
Future<Unit> fetch(String pokemonId) async => _pokemonRequest(
() => fetchPokemonFromUserInput(pokemonId),
);
/// Generic private method to perform request and update the state.
Future<Unit> _pokemonRequest(
TaskEither<String, Pokemon> Function() request,
) async {
state = RequestStatus.loading();
final pokemon = request();
state = (await pokemon.run()).match(
(error) => RequestStatus.error(error),
(pokemon) => RequestStatus.success(pokemon),
);
return unit;
}
}
/// Create and expose provider.
final pokemonProvider = StateNotifierProvider<PokemonState, RequestStatus>(
(_) => PokemonState(),
);
Display the Pokemon in the app
The last step is implementing the screen that will display the UI of the app. I will not go into the details of this part since it is not strictly related to Functional Programming. This is the final result:
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pokeapi_functional/controllers/pokemon_provider.dart';
void main() {
/// [ProviderScope] required for riverpod state management
runApp(ProviderScope(child: MyApp()));
}
class MyApp extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
/// [TextEditingController] using hooks
final controller = useTextEditingController();
final requestStatus = ref.watch(pokemonProvider);
useEffect(() {
/// Fetch the initial pokemon information (random pokemon).
Future.delayed(Duration.zero, () {
ref.read(pokemonProvider.notifier).fetchRandom();
});
}, []);
return MaterialApp(
title: 'Fpdart PokeAPI',
home: Scaffold(
body: Column(
children: [
/// [TextField] and [ElevatedButton] to input pokemon id to fetch
TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Insert pokemon id number',
),
),
ElevatedButton(
onPressed: () => ref
.read(
pokemonProvider.notifier,
)
.fetch(
controller.text,
),
child: Text('Get my pokemon!'),
),
/// Map each [RequestStatus] to a different UI
requestStatus.when(
initial: () => Center(
child: Column(
children: [
Text('Loading intial pokemon'),
CircularProgressIndicator(),
],
),
),
loading: () => Center(
child: CircularProgressIndicator(),
),
/// When either is [Left], display error message 💥
error: (error) => Text(error),
/// When either is [Right], display pokemon 🤩
success: (pokemon) => Card(
child: Column(
children: [
Image.network(
pokemon.sprites.front_default,
width: 200,
height: 200,
),
Padding(
padding: const EdgeInsets.only(
bottom: 24,
),
child: Text(
pokemon.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
),
],
),
),
),
],
),
),
);
}
}
- We use
HookConsumerWidget
from flutter_hooks. This widget allows us to use theuseTextEditingController
,ref.watch
, anduseEffect
hooks. - We use the
when
method provided by the freezedRequestStatus
class to force our UI to define what to display for each of the 4 states in the app.
The app is complete! You can find the complete source code in the fpdart repository:
That's all for today. If you liked the article and would like to stay updated about Functional Programming and fpdart, just follow me on Twitter at @SandroMaglione and subscribe to my newsletter here below. Thanks for reading.