The error handling is preparing the application to respond well when something goes wrong.
In previous articles we have been very optimistic. We assume the server always responds, the user always has internet, and the data always arrives perfectly.
But reality is cruel. Servers go down, WiFi cuts out, and APIs change.
If you don’t manage this, your user will see an infinite blank screen or, worse yet, the app will crash. Today we are going to learn how to manage the chaos and display it elegantly using the FutureBuilder widget.
Handling Errors in the Service
The defense starts in the kitchen (in our UserService). Until now, we only checked if the code was 200. But what if I don’t have internet? http.get will throw an exception and the App will die.
We need to wrap the call in a try-catch block.
class UserService {
Future<List<User>> getUsers() async {
final url = Uri.https('jsonplaceholder.typicode.com', '/users');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
// ... parsing logic (seen in the previous article) ...
return usuarios;
} else {
// The server responded, but with an error (e.g., 404 Not Found)
throw Exception('Error ${response.statusCode}: Users not found');
}
} on http.ClientException catch (e) {
// HTTP Client error, e.g., connection issue
throw Exception('Could not complete the connection: $e');
} on FormatException catch (e) {
// The response did not have the expected format
throw Exception('Server response is not valid: $e');
}
}
}
Now our service is robust. If something fails, it throws a controlled Exception with a message we can display.
The Problem with Manual setState
To display this data on the screen, until now we would have had to do this in our StatefulWidget:
- Create
bool isLoading = truevariable. - Create
String error = ''variable. - Create
List<User> users = []variable. - Call the API in
initState. - Use
setStateto turn off loading, save the data, or save the error.
This is a lot of repetitive code (“Boilerplate”). To simplify this, Flutter gives us the FutureBuilder.
FutureBuilder for Displaying the Wait State
The FutureBuilder is a Widget that builds itself based on the state of a Future.
It listens to a “promise” and rebuilds automatically when the promise changes state (from “Waiting” to “Completed” or “Error”).
It has two key properties:
future: The asynchronous task we are waiting for (userService.getUsers()).builder: A function that gives us thecontextand asnapshot(an instantaneous picture of how the task is going).
Analyzing the AsyncSnapshot
The snapshot object has the information we need:
snapshot.connectionState: Are we waiting (waiting) or is it done (done)?snapshot.hasData: Do we have valid data?snapshot.hasError: Did the task fail?snapshot.data: The data itself (our list of users).snapshot.error: The exception object if it failed.
Complete Implementation
Let’s see what a professional screen that handles the 3 states (Loading, Error, Data) looks like.
class UserListScreen extends StatefulWidget {
@override
_UserListScreenState createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
final UserService _userService = UserService();
// Store the Future in a variable to avoid unnecessary reloads
late Future<List<User>> _futureUsers;
@override
void initState() {
super.initState();
_futureUsers = _userService.getUsers();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Users with FutureBuilder")),
body: FutureBuilder<List<User>>(
future: _futureUsers, // 1. What we are waiting for
builder: (context, snapshot) {
// CASE 1: Still loading
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
// CASE 2: An error has occurred
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 50),
Text('Could not load users'),
ElevatedButton(
onPressed: () {
// Trick to retry: reassign the future
setState(() {
_futureUsers = _userService.getUsers();
});
},
child: Text("Retry"),
)
],
),
);
}
// CASE 3: We have data (Success)
if (snapshot.hasData) {
final usuarios = snapshot.data!; // The ! ensures it is not null
return ListView.builder(
itemCount: usuarios.length,
itemBuilder: (ctx, i) => ListTile(
title: Text(usuarios[i].name),
subtitle: Text(usuarios[i].email),
leading: CircleAvatar(child: Text(usuarios[i].name[0])),
),
);
}
// Default case (rarely reached)
return Text("No data");
},
),
);
}
}
Notice that I stored _futureUsers in initState.
If you put future: _userService.getUsers() directly inside the build, every time you touch the screen or open the keyboard, Flutter will launch the HTTP request again. Storing it in a variable avoids duplicate calls.