flutter-futurebuilder-manejo-errores

Error Handling and FutureBuilder in Flutter

  • 4 min

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');
    }
  }
}
Copied!

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:

  1. Create bool isLoading = true variable.
  2. Create String error = '' variable.
  3. Create List<User> users = [] variable.
  4. Call the API in initState.
  5. Use setState to 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 the context and a snapshot (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");
        },
      ),
    );
  }
}
Copied!

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.