This is the first post on my profile, and I will talk about streams in Flutter with an example of using them to consume data from an API.
What is a stream and how does it work?
In Dart, a Stream is a way to handle asynchronous data over time, similar to an event pipeline. It allows you to listen to data as it becomes available, making it perfect for scenarios where you expect multiple values over time, like receiving API responses or handling user input.
Instead of waiting for a single value (like with Future), Stream emits a sequence of values, either one after the other (for example, real-time data from a REST API) or intermittently. You can "subscribe" to a stream using a listener, and each time a new value arrives, the listener triggers a function. This makes streams a powerful alternative to setState, allowing your UI to react dynamically to changes without manual state updates.
First Steps
First, we will create a new Flutter project. Use the following command to create the project with the name app_stream_api_users:
flutter create app_stream_api_users
Next, we will install the package to make the API call. The package will be http. To add it to your project, use the following command:
flutter pub add http
Next, we'll create a class to handle the API call. In the example, I used Dart's native call method, which allows you to execute the class simply by instantiating it, without needing to specify a method name.
import 'package:app_stream_api_users/dto/user_dto.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
class UserHttp {
final String apiUrl = 'https://reqres.in/api/users';
Future<List<UserDTO>> call() async {
final response = await http.get(Uri.parse('$apiUrl'));
if (response.statusCode == 200) {
final List<dynamic> jsonData = jsonDecode(response.body)['data'];
return jsonData.map((user) => UserDTO.fromJson(user)).toList();
} else {
throw Exception('Failed to load users');
}
}
}
Now, we will create a Data Transfer Object (DTO), which is a concept similar to a model. The purpose of the DTO is to represent the data structure we will work with when consuming data from the API. It will help us efficiently manage the data we receive, making it easier to analyze and use throughout our application.
class UserDTO {
final int id;
final String email;
final String firstName;
final String lastName;
final String avatar;
UserDTO({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.avatar,
});
factory UserDTO.fromJson(Map<String, dynamic> json) {
return UserDTO(
id: json['id'],
email: json['email'],
firstName: json['first_name'],
lastName: json['last_name'],
avatar: json['avatar'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'first_name': firstName,
'last_name': lastName,
'avatar': avatar,
};
}
}
Now, I won’t create many files. I will place the following code directly in the main.dart
:
import 'dart:async';
import 'package:app_stream_api_users/dto/user_dto.dart';
import 'package:app_stream_api_users/http/get_all_users_http.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter App Stream',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter App Stream'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final StreamController<List<UserDTO>> _controllerUser =
StreamController<List<UserDTO>>.broadcast();
@override
void initState() {
super.initState();
getUsers();
}
@override
void dispose() {
super.dispose();
_controllerUser.close();
}
void getUsers() async {
final UserHttp http = UserHttp();
List<UserDTO> users = await http();
_controllerUser.add(users);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: StreamBuilder<List<UserDTO>>(
stream: _controllerUser.stream,
initialData: [],
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Text(
'The current value is:',
),
StreamBuilder<List<UserDTO>>(
stream: _controllerUser.stream,
initialData: [], // Começa com uma lista vazia
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator(); // Mostra um loading enquanto carrega
} else if (snapshot.hasError) {
return const Text('Error loading users');
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Text('No users found');
} else {
final users = snapshot.data!;
return Expanded(
child: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final UserDTO user = users[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(user.avatar),
),
title: Text('${user.firstName} ${user.lastName}'),
subtitle: Text(user.email),
);
},
),
);
}
},
),
],
);
},
),
),
);
}
}
Explanation of the code
Here, we create our StreamController, which will be typed with the data returned from our API. At the end, the broadcast() function is used, which allows multiple listeners to subscribe to the stream simultaneously.
final StreamController<List<UserDTO>> _controllerUser =
StreamController<List<UserDTO>>.broadcast();
Next, we create the function for our StreamController
to receive data from the API, and we place it in initState
, which is the method called to execute initialization tasks that are essential for the widget's functionality, allowing us to run code before the build()
method. We close the StreamController
after the widget is destroyed to prevent memory leaks. This is done in the dispose
method.
@override
void initState() {
super.initState();
getUsers();
}
@override
void dispose() {
super.dispose();
_controllerUser.close();
}
void getUsers() async {
final UserHttp http = UserHttp();
List<UserDTO> users = await http();
_controllerUser.add(users);
}
And finally, the StreamBuilder
listens to the _controllerUser
stream and updates the UI with the latest user data. It handles loading states, errors, and empty data gracefully. If there are users, it displays them in a scrollable list with their avatars and details.
StreamBuilder<List<UserDTO>>(
stream: _controllerUser.stream,
initialData: [],
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Text(
'The current value is:',
),
StreamBuilder<List<UserDTO>>(
stream: _controllerUser.stream,
initialData: [], // Começa com uma lista vazia
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator(); // Mostra um loading enquanto carrega
} else if (snapshot.hasError) {
return const Text('Error loading users');
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Text('No users found');
} else {
final users = snapshot.data!;
return Expanded(
child: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final UserDTO user = users[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(user.avatar),
),
title: Text('${user.firstName} ${user.lastName}'),
subtitle: Text(user.email),
);
},
),
);
}
},
),
],
);
},
),
Top comments (2)
Nice article
very good.