Flutter FutureBuilder: Fetch & Parse Live API Data Tutorial (2026)
Last Updated: | Author: Deep Singh
If you've ever tried to load data from a REST API in Flutter, you know the drill: fire off a request, wait for the response, parse the JSON, and render it. But things get messy fast — the request might fail, the list might be empty, or the user might navigate away before the data arrives.
That's where FutureBuilder shines. It gives you one clean place to handle all four states of an async operation: loading, error, empty, and loaded. In this tutorial, we'll use it to fetch a list of users from a public REST API, parse nested JSON objects, and render them in a list.
Adding the HTTP Plugin
The easiest way to add the plugin is to run this command in your project folder:
flutter pub add http
Or add it manually to your pubspec.yaml file under dependencies:
dependencies:
http: ^1.2.2
Then run flutter pub get.
Note on plugin versions: The version ^0.13.1 used in the original tutorial is out of date. The http package is now on version 1.x. The API is similar, but a few things have changed — you now pass a Uri object to http.get(), for example. The code below uses the current API.
The Full Code
Here's a complete working example. It fetches users from JSONPlaceholder, parses nested company and address objects, and displays everything in a ListView.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Live Data Calling',
debugShowCheckedModeBanner: false,
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// Hold the future in state so it isn't recreated on every rebuild.
late final Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_usersFuture = getData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Live Data Calling'),
),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: (context, snapshot) {
// 1. Error state
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 60, color: Colors.red),
const SizedBox(height: 16),
Text(
'Something went wrong.\n${snapshot.error}',
textAlign: TextAlign.center,
),
],
),
),
);
}
// 2. Loading state
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
// 3. Empty state
final users = snapshot.data!;
if (users.isEmpty) {
return const Center(child: Text('There is no data'));
}
// 4. Loaded state
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
leading: CircleAvatar(
child: Text(user.id.toString()),
),
title: Text(user.name),
subtitle: Text(user.company.name),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(user.address.city),
Text(
'${user.address.geo.lat}, ${user.address.geo.lng}',
style: const TextStyle(fontSize: 12),
),
],
),
);
},
);
},
),
);
}
}
Future<List<User>> getData() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
final List<dynamic> jsonList =
jsonDecode(response.body) as List<dynamic>;
return jsonList
.map((json) => User.fromJson(json as Map<String, dynamic>))
.toList();
}
class User {
final int id;
final String name;
final Company company;
final Address address;
const User({
required this.id,
required this.name,
required this.company,
required this.address,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
company: Company.fromJson(json['company'] as Map<String, dynamic>),
address: Address.fromJson(json['address'] as Map<String, dynamic>),
);
}
}
class Company {
final String name;
const Company({required this.name});
factory Company.fromJson(Map<String, dynamic> json) {
return Company(name: json['name'] as String);
}
}
class Address {
final String street;
final String city;
final Geo geo;
const Address({
required this.street,
required this.city,
required this.geo,
});
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
street: json['street'] as String,
city: json['city'] as String,
geo: Geo.fromJson(json['geo'] as Map<String, dynamic>),
);
}
}
class Geo {
final String lat;
final String lng;
const Geo({required this.lat, required this.lng});
factory Geo.fromJson(Map<String, dynamic> json) {
return Geo(
lat: json['lat'] as String,
lng: json['lng'] as String,
);
}
}
How the Code Works
Let's walk through the important parts:
1. Holding the Future in State
late final Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_usersFuture = getData();
}
This is one of the most commonly missed details. If you call getData() directly inside the FutureBuilder's future parameter, the request fires again on every rebuild — and FutureBuilder rebuilds whenever setState() is called anywhere in the widget. By creating the future once in initState(), we ensure the network request fires exactly once.
2. The Four States of FutureBuilder
We check the snapshot in this order:
- Error —
snapshot.hasErroris true. Show a friendly error with the message. - Loading —
snapshot.hasDatais false. Show a spinner. - Empty — data loaded but the list is empty. Show "no data".
- Loaded — data loaded and has items. Render the list.
The order matters. If you check hasData before hasError, you might try to render data when there is none.
3. Checking the HTTP Status
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
Without this check, a 500 error from the server would still get parsed as JSON, and you'd end up with confusing errors. Throwing here routes the failure into the FutureBuilder's error branch.
4. Parsing Nested JSON
The API returns objects inside objects — user.company.name, user.address.geo.lat. Each nested object gets its own class with its own fromJson factory. The User.fromJson calls Company.fromJson and Address.fromJson, and Address.fromJson calls Geo.fromJson. This mirrors the JSON structure and keeps each class small and focused.
5. Type Casting with `as`
json['company'] as Map<String, dynamic>
The as cast tells Dart to treat the value as a specific type. If the API returns something unexpected, this throws immediately — which is what we want, since it routes into our error state. In production, consider defensive null checks or a code generator like json_serializable.
6. Using `!` After a Null Check
final users = snapshot.data!;
We already verified snapshot.hasData is true in the line above, so the ! operator is safe here. It tells Dart to treat snapshot.data as non-null.
Common Pitfalls to Avoid
Here are the mistakes that trip up most developers when working with FutureBuilder:
- Creating the future inside `build()`. This fires a new network request on every rebuild — sometimes hundreds of times per second if animations are running. Always create it in
initState(). - Forgetting the error branch. If the API is down and you don't handle
hasError, your app shows an infinite spinner. Users hate that more than an error message. - Checking `hasData` before `hasError`. If an error occurs mid-parse (e.g., a type cast fails),
hasDatacan befalseeven thoughhasErroristrue. Order matters. - Calling `setState()` in the builder. The
FutureBuilderbuilder is a pure function — it should just read from the snapshot. Any state changes belong elsewhere. - Not using a fresh future after a refresh. If you add a pull-to-refresh, you need to update
_usersFutureinsetState(). Otherwise, the old completed future keeps returning its cached result. - Assuming `hasData` means the list has items. An empty list is still data. Always check
users.isEmptyseparately if "no results" is a meaningful state for your UI.
If you find yourself writing a lot of FutureBuilder code, consider moving to a state management solution like Riverpod, Bloc, or Provider. They give you cleaner separation between your data layer and your UI, and they handle caching and refresh much more naturally than FutureBuilder.
Wrapping Up
You now know how to use FutureBuilder to fetch live data from a REST API, parse nested JSON, and gracefully handle the loading, error, and empty states. This is one of the most reusable patterns in Flutter — you'll use it in almost every app that talks to a backend.
The next natural steps are adding pull-to-refresh, caching the response locally so the app works offline, and moving the fetch logic into a dedicated service class so your widgets stay clean.
No comments:
Post a Comment