Contact Me
Blog

Flutter Bloc: Loading Remote Data (Part One)

Bloc is a widely-used state management library for Flutter, which aims to keep business logic separate from an app's user interface.

One common use for Bloc is to handle asynchronous work such as fetching data from an API, updating the UI as appropriate. That's the aspect I'm going to cover in this series of posts, which is aimed at people with a little Flutter experience but who are new to Bloc.

There's a lot to cover; the end result will be a complete app; albeit with just the one feature &mdash displaying a random list of jokes.

This first part lays the groundwork, by integrating with a free API — a part of the data layer. Part two will focus on Bloc's role in executing the business logic; and how it integrates with the data layer on the one hand, and the user interface on the other. Part three will focus on the user interface and how it communicates with Bloc through states and events.

The API

For the purposes of this tutorial, the API I'm going to use is a service that provides (bad, often terrible) jokes. It doesn't require API keys or any authentication, which makes the implementation a lot more straightforward; I'd prefer not to get bogged down with that sort of complexity, so that I can primarily look at Bloc.

Feel free to swap it out for a more-useful (or funnier!) API; but be aware ypu'll need to make a number of changes. However, most of the principles I'm going to cover are applicable to many if not most APIs.

The documentation for the API is here; one of the endpoints returns a selection of random jokes.

This is what the API response looks like:

[
  {
    "type": "general",
    "setup": "What do you call fake spaghetti?",
    "punchline": "An impasta.",
    "id": 450
  },
  // ... + 9 more
]

You can also view this directly by clicking here.

First step, then, is to create a model class that represents a single joke. We need to include a factory method for creating an instance from JSON.

There are various tools available for automating this; but there are so few fields that it's hardly worth the hassle. I often prefer coding models by hand anyway, though your mileage may vary.

I'm assuming you've already installed Flutter and created a new app. I've named mine jokes; if you choose soomething different then you'll need to change a few package imports.

You can create a new app on the commandline with:

flutter create [APP NAME]

e.g.

flutter create jokes
cd jokes

Create a folder; lib/data then lib/data/models.

Here's the code for the model, placed in the latter folder with the filename joke.dart:

// lib/data/models/joke.dart
class Joke {
  final int id;
  final String type;
  final String setup;
  final String punchline;

  const Joke({
    required this.id,
    required this.type,
    required this.setup,
    required this.punchline,
  });

  factory Joke.fromJson(Map<String, dynamic> json) => 
    Joke(
      id: json['id'],
      type: json['type'],
      setup: json['setup'],
      punchline: json['punchline'],
    );
}

Next step is to create a client class, which calls the API to get a list of random jokes. It will serve the role of data provider.

Before we create this, we're going to define a set of custom error classes to cover different eventualities.

A few things could go wrong:

  • The network request could fail to complete
  • The response could indcate a non-successful request; i.e. the status code is not in the 200 range (not found, server error etc)
  • The process of parsing the JSON could fail

We'll create a class for each, as well as an abstract parent class that they all extend.

They'll all be very small, so let's just put them all in one file.

Create a file — lib/data/api/errors.dart:

// lib/data/api/errors.dart
sealed class JokesError extends Error {}

final class JokesNetworkError extends JokesError {
  Object exception;

  JokesNetworkError({
    required this.exception,
  }): super();
}

final class JokesHttpError extends JokesError {
  final int statusCode;

  JokesHttpError({
    required this.statusCode,
  }): super();
}

final class JokesParseError extends JokesError {}

Pretty self-explanatory. If the HTTP status code isn't in the 200 range then we'll throw an instance of JokesHttpError and include the actual code. The client throws an exception, we pass that to an instance of JokesNetworkError. There's not much merit in passing the parse error details to the UI, so no properties for JokesParseError; though of course you may wish to log this somewhere.

Next up, a service class which integrates with the API. It's essentially going to be a wrapper around an HTTP client provided by the extemely popular http package, which will take care of converting the response into a list of the Joke model we created earlier, as well as handle errors.

When I'm creating such a service, I tend to make the underlying HTTP client an optional parameter, creating an instance in the class if it's not provided. This is very useful when it comes to testing — as we'll see later — as you'll probably want to inject a mock rather than use a "real" HTTP client.

The http library includes its own mock client.

Here's the basic class, which does just that — as well as define the base URL of the API as a constant:

// lib/data/api/jokes_api_client.dart
import 'package:http/http.dart' as http;

class JokesApiClient {

  final http.Client client;
  static const baseUrl = 'https://official-joke-api.appspot.com';

  JokesApiClient({
    http.Client? client,
  }): client = client ?? http.Client();
}

For this simple example, we're only going to implement a single method; to fetch a list of random jokes as per the endpoint above.

Since there's no guarrantee that it will return successfully; if it's a mobile app then there's every chance the signal is poor or out-of-range. Hence we're return either a list of jokes, or an error.

The dartz package provides an Either class for this; it's something of a workaround to the fact that Dart doesn't allow multiple return types in same way as, say, Golang does.

Install that next:

flutter pub add dartz

Add the import to the file above:

import 'package:dartz/dartz.dart';

Here's the function signature:

Future<Either<List<Joke>, JokesError>> random() async { }

This might look a little complex, so let's break it down.

If the request suceeds, we want to return a List of Joke instances, hence the type-hinted list:

List<Joke>

On the other hand, if it fails then we want to return an instance of JokesError.

When definining a return type of the Either class, we type-hint the two possible return types; one on the left, one on the right:

Either<List<Joke>, JokesError>

It's an asynchronous call, so we're returning a Future which is expected to return an instance of Either, so one last layer of type-hinting:

Future<Either<List<Joke>, JokesError>>

You don't return an instance of Either directly; rather, you'd use either the Left or Right class; as we'll see in a moment.

Below, then, is a method that tries to call the API, checks the status code and attempts to parse the response. All being well it'll return a list of jokes via the Left class constructor (indicating the left-hand side of the types defined for the Either class we're returning), or the appropriate JokesError instance using the Right class constructor.

Future<Either<List<Joke>, JokesError>> random() async {    
  try {
    final uri = Uri.parse('$baseUrl/random_ten');
    final response = await client.get(uri); 
    if (response.statusCode != 200) {
      return Right(JokesHttpError(statusCode: response.statusCode,));  
    }
    try {
      final json = jsonDecode(response.body);
      return Left(
        json.map((d) => Joke.fromJson(d)).cast<Joke>().toList()
      );
    } catch (_) {
      return Right(JokesParseError());
    }
  } catch (e) {       
    return Right(JokesNetworkError(exception: e,));
  } 
}

Calling other endpoints would likely follow the same approach — and likely re-use the Joke model class — but for demonstration purposes, we'll stick to the one that returns ten random jokes.

Feel free to implement more methods yourself; the link to the documentation is provided earlier in the post.

Testing

Last thing for this part; some unit tests for the new client (and by extension, the model).

Create a new file, tests/jokes_api_client_test.dart with the necessary imports:

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:dartz/dartz.dart';
import 'package:jokes/data/models/joke.dart';
import 'package:jokes/data/api/jokes_api_client.dart';
import 'package:jokes/data/api/errors.dart';

void main() {}

The following extension (added just before the main() method) will help extract the relevant data from the return value, which you'll recall is an instance of the Either class; we'll need to extract the left or right side value as required.

extension EitherX<L, R> on Either<L, R> {
  R asRight() => (this as Right).value;
  L asLeft() => (this as Left).value;
}

The http library includes its own mock HTTP client; you'll recall we included the option to inject a client into the API client, so that's what we'll do:

The first test case provides a valid JSON response, which is expected to result in a list of jokes; we can configure a mock client to return the hard-coded JSON without the tests hitting the "live" API.

Below illustrates how to set up the mock HTTP client, and use that when creating an instance of the client we just built:

//...
void main() {
  test('Fetches and parses jokes', () async {
    String json = '''[{"type":"general","setup":"How come a man driving a train got struck by lightning?","punchline":"He was a good conductor.","id":114},{"type":"general","setup":"Why didn’t the orange win the race?","punchline":"It ran out of juice.","id":338},{"type":"general","setup":"Where do you learn to make banana splits?","punchline":"At sundae school.","id":288},{"type":"general","setup":"How does a train eat?","punchline":"It goes chew, chew","id":6},{"type":"general","setup":"How many tickles does it take to tickle an octopus?","punchline":"Ten-tickles!","id":143},{"type":"general","setup":"What do you call a duck that gets all A's?","punchline":"A wise quacker.","id":202},{"type":"general","setup":"Did you know that protons have mass?","punchline":"I didn't even know they were catholic.","id":103},{"type":"general","setup":"What do you call a suspicious looking laptop?","punchline":"Asus","id":379},{"type":"general","setup":"What do you call an elephant that doesn’t matter?","punchline":"An irrelephant.","id":220},{"type":"general","setup":"I just watched a documentary about beavers.","punchline":"It was the best dam show I ever saw","id":40}]''';

    final httpClient = MockClient((request) async {
      return Response(
        json,
        200,
        headers: {'content-type': 'application/json'});
    });

    final client = JokesApiClient(client: httpClient);

    // ...

Now, we can assert that the left-hand side is returned — i.e., the method has returned valid data — and then check that it's a list of ten jokes.

In addition, we'll check that all four fields of the first joke are as expected.

void main() {
    // ...
    final response = await client.random();
    expect(response.isLeft(), true);
    final jokes = response.asLeft();
    expect(jokes, isA<List<Joke>>());
    expect(jokes.length, 10);
    expect(jokes.first.id, 114);
    expect(jokes.first.type, 'general');
    expect(jokes.first.setup, 'How come a man driving a train got struck by lightning?');
    expect(jokes.first.punchline, 'He was a good conductor.');
  });  
}

Next, we'll mock a response where the status code is outside of the 200-range; in this case, a 404 not found. This time, it should return the right-hand side; an instance of JokesError — more specifically an instance of its sbclass, JokesHttpError — and it should include the correct HTTP status code:

void main() {
  // ...
  test('Returns appropriate error for non 200-range status code', () async {
    final httpClient = MockClient((request) async {
      return Response(
        'Not found',
        404,
        headers: {'content-type': 'application/json'});
    });

    final client = JokesApiClient(client: httpClient);

    final response = await client.random();
    expect(response.isRight(), true);
    expect(response.asRight(), isA<JokesError>());
    expect(response.asRight(), isA<JokesHttpError>());
    expect((response.asRight() as JokesHttpError).statusCode, 404);
  });  
}

Here's a test whereby the client returns invalid JSON; the specific error is expected to be — this time, an instance of JokesParseError:

// ...
void main() {  
  // ...
  test('Returns appropriate error if JSON cannot be parsed', () async {
    final httpClient = MockClient((request) async {
      return Response(
        'Not valid JSON',
        200,
        headers: {'content-type': 'application/json'});
    });

    final client = JokesApiClient(client: httpClient);

    final response = await client.random();
    expect(response.isRight(), true);
    expect(response.asRight(), isA<JokesError>());
    expect(response.asRight(), isA<JokesParseError>());    
  });
}

Finally, if the HTTP client throws an error then we expect to get an instance of JokesNetworkError:

// ...
void main() {  
  // ...
  test('Returns appropriate error if client throws an error', () async {
    final httpClient = MockClient((request) async {
      throw ArgumentError('Invalid request body');
    });

    final client = JokesApiClient(client: httpClient);

    final response = await client.random();
    expect(response.isRight(), true);
    expect(response.asRight(), isA<JokesError>());
    expect(response.asRight(), isA<JokesNetworkError>());    
  });
}

Try running these tests — it's easiest to do this from within VSCode — and ensure they all pass.

That's it for this part; in the next part, we'll integrate this with Bloc. In the meantime, the full source-code for this and subsequent parts is available here.