[Flutter] Using streams for asynchronous requests

Padam Chopra
4 min readDec 8, 2022

--

Extracting streams in a passable wrapper for easier asynchronous task execution.

Stream

If you’re familiar with the observer pattern, think of a stream in Dart as a way to notify observers when data is provided. For others, think of streams as a way to execute code when data is added to them from another source. The example in this article shows how a stream can be used to update content on screen when a network request finishes.

Context

  • In Flutter, a stream can be controlled using a StreamController<T> class, where T is the type of data you expect to provide. The data will be passed as a parameter to your function, wherever you choose to listen to the stream (example code below).
  • The StreamController class gives you access to two components: a Sink and a Stream. The Sink acts like a box where you can throw in data of type T. The Stream, provided by the controller, then has access to this newly added data.
  • I use UseCases to interact between my screen/presentation components and data. These UseCases can be used to connect visual components to repositories (optional) or to make network requests directly.

Code

Suppose you have the following state for a stateful widget:

class _HomeState extends State<HomeScreen> {
var loading = true
var totalViews = 0
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: loading
? CircularProgressIndicator()
: Text("$totalViews views on article"),
),
);
}
}

You plan to make a GET request to your service endpoint baseUrl.com/api/views to get the value for your totalViews variable. Let’s consider what you can add to the state class to achieve this.

@override
void initState() {
super.initState();
fetchData();
}

void fetchData() async {
// side note: good idea to wrap get in a try-catch
var response = await http.get("baseUrl.com/api/views");
if (response.statusCode == 200) {
setState(() {
loading = false;
totalViews = jsonDecode(response.body)["views"];
});
}
// can also add some error handling to inform user
}

These additions do the job here, but it quickly starts getting complicated as you start adding multiple screens or different component classes. If something outside the _HomeState needs to make the request instead, you will have to pass on the fetchData function as a callback to your widgets. This would require you to declare a function parameter for the other widget(s). The problem arises when you want to add parameters to the function in future to make different kinds of view count requests, or in a more generic sense- add more logic to what you request should look like. You will have to change the parameter type declarations throughout the hierarchy of passing it. This is where UseCase comes in to save us.

abstract class UseCase<T> {
final _controller = StreamController<T>();
Stream<T> get stream => _controller.stream;

// can optionally make this return a boolean whether data was added or not
void add(T obj) {
if (_controller.isClosed) return;
_controller.sink.add(obj);
}

void addError(CustomErrorModel error) {
_controller.sink.addError(error);
}

void close() {
_controller.close();
}
}

This use case abstract class can then be extended to implement different types of use cases. We can use it to implement a GetViewsUseCase and extract our data fetching/processing logic from the UI state class- provide a single responsibility to this new use case and our earlier UI state class.

class GetViewsUseCase extends UseCase<Int> { // <Int> since it returns int
// the next two lines are only useful if you have repositories
// other viewers who do not know what they are can ignore it
final MyRepository _repository;
GetViewsUseCase() : _repository = getSingletonRepository();

void get() async {
// if using repository, you can do something like:
// var result = await _repository.getViewsFor("Home");
try {
var response = await http.get("baseUrl.com/api/views");
if (response.statusCode == 200) {
var views: Int = jsonDecode(response.body)["views"];
add(views);
return;
}
} catch(_) {};
addError(CustomErrorModel(...));
}
}

You can then add this new use case, remove the previousfetchData function, modify init function, and override dispose in _HomeState .

var viewsUseCase = widget.viewsUseCase ?? GetViewsUseCase();

@override
void initState() {
super.initState();
viewsUseCase.get();
viewsUseCase.stream.listen((views) { // to listen to data added to sink
setState(() {
loading = false;
totalViews = views;
});
}, onError: (error) { // to listen to errors added
loading = false;
// handle showing error to user;
});
}

@override
void dispose() {
viewsUseCase.close(); // only if no one else is listening as well
super.dispose();
}

This use case can easily be passed onto other places that are expected to listen to the same call of your request. Let’s look at what you will need to change if you want to pass more information to your service endpoint to get different kinds of view counts. Firstly, you will need to make your get() method take in additional parameters based on which you can modify your request. In our earlier approach, you would have to change signature of function passed down to each of the widgets, but now you can jut modify your use case’s get function and pass in extra parameters only where you call it (or avoid doing that by making parameters optional.

The above parameter addition is just one example of how a use case paired stream can be used to handle asynchronous tasks. This article also highlights how a use case can help you separate your business logic from presentation logic. You can easily modify use cases to handle more complicated and advanced logic. For example:

  • Adding factories/mappers- convert response json or one data class to another.
  • Combining use cases to create new use cases — use case to get views on a user’s profile and a search use case that returns list of views can be combined to return a list of users with corresponding information about views on their profile.

Fin.

--

--

Padam Chopra

Computer Science @UWaterloo ’23. Interested in building products, especially native mobile apps and Flutter.