Once you know how to build Flutter widgets and manage local state with setState, the next question is almost always the same: “How do I load real data from the internet?” That question leads straight to FutureBuilder — the Flutter widget that connects an async operation to your UI.
Without FutureBuilder, you end up manually managing boolean flags like isLoading, hasError, and isDataReady, calling setState in the right places, and hoping you didn’t miss a state transition. FutureBuilder handles all of that for you. You give it a Future, it watches it, and it rebuilds your UI every time the state changes — from loading to done.
This flutter futurebuilder tutorial covers the complete journey: what a Future is, how FutureBuilder works, the three-state UI pattern, fetching JSON with the http package, parsing it into model classes, handling errors with retry, and adding pull-to-refresh. Every example uses a real public API so you can run it today.
- StatefulWidget and setState() — FutureBuilder lives inside a StatefulWidget
- Dart async/await and Future basics — covered in the Dart crash course (Part 0)
- ListView.builder — the data from FutureBuilder drives a list in most apps
- Know how to add a package to pubspec.yaml
1. Quick Refresher: What Is a Future?
A Future<T> is a Dart value that will be available later — like an order ticket at a restaurant. You don’t have the food yet, but you will once the kitchen is done. HTTP requests, file reads, and database queries all return Futures.
// A Future that resolves to a String — available after 1 second
Future<String> greetLater() async {
await Future.delayed(const Duration(seconds: 1));
return 'Hello, Flutter!';
}
// A Future that could fail — like an HTTP request
Future<String> fetchData() async {
final response = await http.get(Uri.parse('https://api.example.com/data'));
if (response.statusCode == 200) {
return response.body; // success: resolves with data
} else {
throw Exception('HTTP ${response.statusCode}'); // failure: resolves with error
}
}
// Three ways a Future ends:
// 1. Still running → ConnectionState.waiting
// 2. Completed with data → ConnectionState.done + snapshot.hasData = true
// 3. Completed with error → ConnectionState.done + snapshot.hasError = true
FutureBuilder watches a Future and rebuilds your widget each time one of these three states is reached. That is all it does — but that is exactly what you need.
2. FutureBuilder: Core Idea and Anatomy
FutureBuilder is a widget. You give it a Future and a builder function. Every time the Future’s state changes, Flutter calls the builder with an updated AsyncSnapshot, and whatever widget the builder returns is what gets shown on screen:
FutureBuilder<List<User>>( // ← T is your data type
future: _usersFuture, // ← the Future to watch
builder: (context, snapshot) { // ← called every time state changes
// snapshot.connectionState → waiting, done
// snapshot.hasData → true when data arrived
// snapshot.hasError → true when an error occurred
// snapshot.data → the actual List<User> (or null)
// snapshot.error → the error object (or null)
// Return a widget based on snapshot state:
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (snapshot.hasData) {
return Text('Got ${snapshot.data!.length} users');
}
return const Text('No data');
},
)
Without
FutureBuilder you would write: bool _isLoading = true, String? _error, List<User>? _users, and call setState in 3 different places inside your fetch function. FutureBuilder replaces all of that — the snapshot is your loading/error/data state.
3. Understanding ConnectionState
The snapshot.connectionState property tells you exactly where in the Future’s lifecycle you are:
| ConnectionState | Meaning | What to show |
|---|---|---|
none | No future assigned yet | Rarely seen — treat same as waiting |
waiting | Future is running, no result yet | ✅ Spinner / skeleton loader |
active | Intermediate values (Streams only) | Ignore for one-shot Futures |
done | Future completed — check hasError or hasData | ✅ Data list OR error message |
// ConnectionState flow for a typical HTTP call:
//
// Future created → waiting → done
// ↓
// hasError? hasData?
// ↓ ↓
// Error UI Data UI
//
// snapshot.connectionState == ConnectionState.waiting → show spinner
// snapshot.connectionState == ConnectionState.done
// + snapshot.hasError → show error message
// + snapshot.hasData → show list / detail / whatever
// + neither → show "no results" empty state
4. The Three-State Pattern: Loading, Error, Data
Every FutureBuilder you write should handle exactly three states. This is the core pattern — memorise it and everything else is just filling in the UI:
builder: (context, snapshot) {
// ── State 1: Loading ─────────────────────────────────────────────────
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// ── State 2: Error ───────────────────────────────────────────────────
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 56, color: Colors.grey),
const SizedBox(height: 16),
Text(
'Something went wrong',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'${snapshot.error}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
),
);
}
// ── State 3: Data (including empty list) ─────────────────────────────
final items = snapshot.data ?? [];
if (items.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No items found'),
],
),
);
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index].name),
),
);
},
5. Step 1 — Add the http Package
Flutter doesn’t include HTTP networking in its core library. Add the http package from pub.dev:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
http: ^1.2.1 # check pub.dev/packages/http for the latest version
# Then run:
flutter pub get
Hot reload does not pick up new pubspec.yaml dependencies. After running
flutter pub get, stop your app and do a full restart. If you hit build errors after adding http, our Top 10 Flutter Build Errors guide covers the most common causes.
Now import both libraries at the top of your Dart file:
import 'dart:convert'; // jsonDecode
import 'package:http/http.dart' as http; // http.get, http.post etc.
// The 'as http' alias prevents name collisions and makes call sites clear:
// http.get(...) not just get(...)
// http.Response not just Response
6. Step 2 — Create a Model Class with fromJson
When jsonDecode parses JSON it returns a Map<String, dynamic> or List<dynamic>. Working directly with those is messy — you lose type safety and autocomplete. A model class with a fromJson factory constructor solves both problems.
// ── How jsonDecode works ──────────────────────────────────────────────────
import 'dart:convert';
void main() {
// JSON string → Dart Map
const jsonString = '{"id": 1, "name": "Alice", "email": "[email protected]"}';
final Map<String, dynamic> map = jsonDecode(jsonString);
print(map['id']); // → 1 (int)
print(map['name']); // → Alice (String)
// Problem: map['name'] is dynamic — no type safety, no autocomplete
// Solution: parse into a typed model class
}
// ── A typed model class ───────────────────────────────────────────────────
class User {
const User({
required this.id,
required this.name,
required this.email,
required this.username,
});
final int id;
final String name;
final String email;
final String username;
// fromJson: create a User from a Map<String, dynamic>
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
username: json['username'] as String,
);
}
// toJson: convert back to Map (useful for sending data to an API)
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'username': username,
};
@override
String toString() => 'User($id: $name)';
}
// ── Parsing a list of users ───────────────────────────────────────────────
List<User> parseUsers(List<dynamic> jsonList) {
return jsonList
.map((item) => User.fromJson(item as Map<String, dynamic>))
.toList();
}
// Usage example:
// final rawJson = '[{"id":1,"name":"Alice",...}, {"id":2,...}]';
// final list = jsonDecode(rawJson) as List<dynamic>;
// final users = parseUsers(list);
// print(users[0].name); // → Alice (typed, autocomplete works)
Real APIs often have nested objects:
"address": {"street": "Main St", "city": "Springfield"}. Create a separate Address model class with its own fromJson, then call Address.fromJson(json['address']) inside User.fromJson. The pattern is the same at every level of nesting.
7. Step 3 — Write the Async Fetch Function
The fetch function does three things: makes the HTTP request, checks the status code, and parses the JSON. Keep it outside your widget class so it is easy to test and reuse:
// Fetch a list of users from JSONPlaceholder (free public test API)
Future<List<User>> fetchUsers() async {
// 1. Build the URI — always use Uri.parse, not a raw string
final uri = Uri.parse('https://jsonplaceholder.typicode.com/users');
// 2. Make the HTTP GET request and await the response
final response = await http.get(uri);
// 3. Check the status code
if (response.statusCode == 200) {
// 4. Decode the JSON body — response.body is a String
final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>;
// 5. Parse into typed model objects and return
return parseUsers(jsonList);
} else {
// Throwing an Exception makes snapshot.hasError = true in FutureBuilder
throw Exception(
'Failed to load users (HTTP ${response.statusCode})',
);
}
}
// ── Why throw instead of returning an empty list? ─────────────────────────
// Returning [] on error hides the problem — the user sees an empty list
// with no indication anything went wrong. Throwing an exception lets
// FutureBuilder surface it through snapshot.hasError so you can show
// a helpful error message with a retry button.
// ── Fetching a single item by ID ──────────────────────────────────────────
Future<User> fetchUser(int id) async {
final uri = Uri.parse('https://jsonplaceholder.typicode.com/users/$id');
final response = await http.get(uri);
if (response.statusCode == 200) {
final Map<String, dynamic> json =
jsonDecode(response.body) as Map<String, dynamic>;
return User.fromJson(json);
} else {
throw Exception('User $id not found (HTTP ${response.statusCode})');
}
}
// ── Adding headers (e.g., auth token) ────────────────────────────────────
Future<List<User>> fetchUsersAuthenticated(String token) async {
final uri = Uri.parse('https://api.example.com/users');
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
);
if (response.statusCode == 200) {
return parseUsers(jsonDecode(response.body) as List<dynamic>);
} else {
throw Exception('Auth failed (HTTP ${response.statusCode})');
}
}
8. Step 4 — Store the Future in initState (Not in build)
This is the single most important rule about FutureBuilder. If you call your fetch function directly in the future: parameter, it gets called on every rebuild — creating a new HTTP request each time anything changes in the widget tree. Store the Future in a field and create it once in initState:
// ❌ Wrong — fetchUsers() is called on EVERY rebuild
// Triggers a new HTTP request whenever parent rebuilds, theme changes, etc.
@override
Widget build(BuildContext context) {
return FutureBuilder<List<User>>(
future: fetchUsers(), // ← called every time build() runs!
builder: ...,
);
}
// ✅ Correct — future created once in initState, stored in a field
class _UserListPageState extends State<UserListPage> {
late Future<List<User>> _usersFuture; // ← stored in state
@override
void initState() {
super.initState();
_usersFuture = fetchUsers(); // ← called exactly ONCE
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<User>>(
future: _usersFuture, // ← reference the stored future
builder: ...,
);
}
}
9. Step 5 — Implement the Three-State UI
With the fetch function ready and the future stored in state, wire the complete three-state builder. Extract it as a named method to keep build() readable:
class UserListPage extends StatefulWidget {
const UserListPage({super.key});
@override
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
late Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_usersFuture = fetchUsers();
}
// Retry: assign a new future and rebuild
void _retry() {
setState(() {
_usersFuture = fetchUsers();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: _buildBody, // ← extracted method keeps build() clean
),
);
}
Widget _buildBody(BuildContext context, AsyncSnapshot<List<User>> snapshot) {
// ── State 1: Loading ─────────────────────────────────────────────────
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// ── State 2: Error ───────────────────────────────────────────────────
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Could not load users',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'${snapshot.error}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
),
),
);
}
// ── State 3: Data ────────────────────────────────────────────────────
final users = snapshot.data ?? [];
if (users.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No users found'),
],
),
);
}
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
leading: CircleAvatar(child: Text(user.name[0])),
title: Text(user.name),
subtitle: Text(user.email),
trailing: Text('@${user.username}',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12)),
);
},
);
}
}
10. Complete Working Example: JSONPlaceholder Users
Here is the full copy-paste-ready app using the free JSONPlaceholder API. Paste this into a fresh main.dart, run flutter pub get, and it works immediately:
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: 'FutureBuilder Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const UserListPage(),
);
}
}
// ── Model ─────────────────────────────────────────────────────────────────
class User {
const User({
required this.id,
required this.name,
required this.email,
required this.username,
required this.phone,
});
final int id;
final String name;
final String email;
final String username;
final String phone;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
username: json['username'] as String,
phone: json['phone'] as String,
);
}
// ── Fetch function (outside widget) ──────────────────────────────────────
Future<List<User>> fetchUsers() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users'),
);
if (response.statusCode == 200) {
final list = jsonDecode(response.body) as List<dynamic>;
return list
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
}
throw Exception('HTTP error ${response.statusCode}');
}
// ── Page ──────────────────────────────────────────────────────────────────
class UserListPage extends StatefulWidget {
const UserListPage({super.key});
@override
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
late Future<List<User>> _usersFuture;
@override
void initState() {
super.initState();
_usersFuture = fetchUsers();
}
void _retry() => setState(() => _usersFuture = fetchUsers());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users'), centerTitle: true),
body: FutureBuilder<List<User>>(
future: _usersFuture,
builder: (context, snapshot) {
// Loading
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Error
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
const Text('Could not load users'),
const SizedBox(height: 8),
Text('${snapshot.error}',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
),
);
}
// Data
final users = snapshot.data ?? [];
if (users.isEmpty) {
return const Center(child: Text('No users found'));
}
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final u = users[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Text(
u.name[0],
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold),
),
),
title: Text(u.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(u.email),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('@${u.username}',
style: TextStyle(
color: Colors.grey.shade500, fontSize: 12)),
Text(u.phone,
style: TextStyle(
color: Colors.grey.shade400, fontSize: 11)),
],
),
),
);
},
);
},
),
);
}
}
11. Adding Retry Logic
The retry pattern is simple: assign a new Future to the state field inside setState. FutureBuilder sees the new future and immediately shows the loading state while the new request runs:
// Retry by creating a fresh Future — FutureBuilder reacts automatically
void _retry() {
setState(() {
_usersFuture = fetchUsers(); // new Future → triggers waiting state → then done
});
}
// ── Enhanced retry with attempt tracking ─────────────────────────────────
int _retryCount = 0;
void _retryWithCount() {
setState(() {
_retryCount++;
_usersFuture = fetchUsers();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Retrying... (attempt $_retryCount)'),
duration: const Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
}
// ── Auto-retry with exponential backoff ───────────────────────────────────
// For more robust retry logic (e.g., wait 1s, 2s, 4s between attempts):
Future<List<User>> fetchUsersWithRetry({int maxAttempts = 3}) async {
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetchUsers();
} catch (e) {
if (attempt == maxAttempts) rethrow; // give up after max attempts
// Wait longer between each retry: 1s, 2s, 4s...
await Future.delayed(Duration(seconds: 1 << (attempt - 1)));
}
}
throw Exception('Should not reach here');
}
12. Pull-to-Refresh with RefreshIndicator
Wrapping the ListView inside a RefreshIndicator adds the standard swipe-to-refresh gesture. The onRefresh callback simply reassigns the future — the same retry pattern:
// In the data branch of your FutureBuilder builder:
// Replace the bare ListView.builder with a RefreshIndicator wrapper
Widget _buildUserList(List<User> users) {
return RefreshIndicator(
onRefresh: _onRefresh, // ← called when user pulls down
child: ListView.builder(
// AlwaysScrollableScrollPhysics ensures pull works even on short lists
physics: const AlwaysScrollableScrollPhysics(),
itemCount: users.length,
itemBuilder: (context, index) {
final u = users[index];
return ListTile(
leading: CircleAvatar(child: Text(u.name[0])),
title: Text(u.name),
subtitle: Text(u.email),
);
},
),
);
}
// onRefresh must return a Future<void>
// The spinner shows until the Future completes
Future<void> _onRefresh() async {
final newFuture = fetchUsers();
setState(() => _usersFuture = newFuture);
await newFuture; // wait so the spinner stays until data arrives
}
// ── Full updated builder with pull-to-refresh ─────────────────────────────
Widget _buildBody(BuildContext context, AsyncSnapshot<List<User>> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
// Error state doesn't need pull-to-refresh — show retry button instead
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('Failed to load'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
),
],
),
);
}
final users = snapshot.data ?? [];
return RefreshIndicator(
onRefresh: _onRefresh,
child: users.isEmpty
? const SingleChildScrollView( // ← needed so pull works on empty list
physics: AlwaysScrollableScrollPhysics(),
child: Center(
heightFactor: 10,
child: Text('No users found — pull to refresh'),
),
)
: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: users.length,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text(users[index].name[0])),
title: Text(users[index].name),
subtitle: Text(users[index].email),
),
),
);
}
13. When NOT to Use FutureBuilder
FutureBuilder is perfect for one-shot async work tied to a widget’s lifecycle. It is the wrong tool in a few situations:
| Situation | Use instead | Why |
|---|---|---|
| Continuous real-time data (Firestore, WebSocket) | StreamBuilder | Futures complete once — Streams emit continuously |
| Data shared across multiple screens | Riverpod / Provider | FutureBuilder re-fetches when its widget rebuilds; state management caches |
| Data already in memory (local list) | setState + ListView.builder | No async involved — FutureBuilder adds unnecessary complexity |
| Complex caching / offline support | State management + local DB | FutureBuilder has no built-in cache — each widget instance re-fetches |
Use
FutureBuilder when the question is: “Load data because this screen is being shown.” Page loads, detail screens, and pull-to-refresh are all perfect fits. If the data needs to survive navigation, be shared with other screens, or update in real time — you need something else.
14. Common Beginner Mistakes
Mistake 1: Calling the fetch function inside the future: parameter
// ❌ Wrong — fetchUsers() called on every build → multiple HTTP requests
FutureBuilder<List<User>>(
future: fetchUsers(), // re-creates a new Future on every rebuild
builder: ...,
)
// ✅ Correct — stored in initState, created exactly once
@override
void initState() {
super.initState();
_usersFuture = fetchUsers();
}
FutureBuilder<List<User>>(
future: _usersFuture, // reference to the single stored Future
builder: ...,
)
Mistake 2: Skipping the error branch
// ❌ Wrong — no error handling; shows nothing if the request fails
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return ListView.builder(...); // crashes if snapshot.data is null after an error
}
// ✅ Correct — always handle all three states
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}'); // at minimum
}
final users = snapshot.data ?? [];
return ListView.builder(...);
}
Mistake 3: Using snapshot.data! without checking hasData first
// ❌ Wrong — snapshot.data could be null if there's an error or no data yet
// The ! force-unwrap throws a Null check operator used on a null value crash
final users = snapshot.data!; // crashes if data is null
// ✅ Correct — use ?? with a safe fallback
final users = snapshot.data ?? [];
// Or check explicitly:
if (!snapshot.hasData) return const Text('No data');
final users = snapshot.data!; // safe here because hasData guarantees non-null
Mistake 4: Returning null from the builder
// ❌ Wrong — every code path must return a Widget, never null
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasData) {
return ListView.builder(...);
}
// Implicit null return here — Dart compile error or runtime crash
}
// ✅ Correct — always have a final fallback
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (snapshot.hasData) {
return ListView.builder(...);
}
return const Text('No data'); // ← always reached if none of the above matched
}
Mistake 5: Expecting fromJson to handle malformed data gracefully
// ❌ Dangerous — if the API changes and 'name' is now missing, this throws
factory User.fromJson(Map<String, dynamic> json) => User(
name: json['name'] as String, // throws if 'name' key doesn't exist
);
// ✅ Safer — provide fallbacks for optional fields
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int? ?? 0,
name: json['name'] as String? ?? 'Unknown',
email: json['email'] as String? ?? '',
);
// During development: always print response.body first to see the real JSON shape:
// print(response.body); // ← add this temporarily to debug JSON structure
15. Interview Q&A
A:
FutureBuilder is a Flutter widget that builds itself based on the latest state of a Future. You give it a Future and a builder function. Flutter calls the builder with an AsyncSnapshot whenever the Future’s state changes — from waiting (loading) to done (data or error). It replaces manual loading/error/data boolean flags and setState calls.
A: Loading (
snapshot.connectionState == ConnectionState.waiting — show a spinner), Error (snapshot.hasError == true — show an error message with a retry button), and Data (snapshot.hasData == true — render the real UI). Always handle all three, including an empty-data case inside the data branch.
A: The
build method can be called many times — whenever the parent rebuilds, the theme changes, orientation changes, etc. If you call your fetch function directly in future: fetchData(), a new HTTP request fires on every rebuild. Storing the Future in a field and creating it once in initState ensures exactly one request per page load.
A:
FutureBuilder works with one-shot async operations — a Future completes once and that’s it. StreamBuilder works with continuous data — a Stream can emit many values over time. Use FutureBuilder for HTTP requests, file reads, and one-time loads. Use StreamBuilder for Firestore real-time listeners, WebSocket feeds, or auth state changes.
A: Call
setState(() { _myFuture = fetchData(); }). Assigning a new Future to the field and calling setState causes FutureBuilder to see a new future, immediately show the loading state, and then transition to data or error when the new request resolves.
A:
jsonDecode returns dynamic — either a Map<String, dynamic> for JSON objects or a List<dynamic> for JSON arrays. Everything inside is untyped. Without a model class, you access fields as user['name'] with no autocomplete and no compile-time checking. A model class with fromJson converts the untyped map into a strongly typed Dart object where user.name is a String the compiler can verify.
16. Related Posts
| Post | Why it’s relevant |
|---|---|
| Dart Crash Course (Part 0) | async, await, Future, and model class patterns are all covered there — read this first if anything here feels unfamiliar. |
| Flutter Widgets: Stateless vs Stateful | FutureBuilder lives inside a StatefulWidget and relies on initState — this is the prerequisite. |
| Flutter ListView.builder: Complete Guide | The data branch of FutureBuilder almost always renders a ListView.builder — all the patterns from that guide apply here. |
| Flutter Search Bar from Scratch | The API search example in that post uses exactly this FutureBuilder pattern — see how it fits into a complete screen. |
| Understanding pubspec.yaml | Adding the http package requires editing pubspec.yaml correctly — this guide covers everything about that file. |
| Top 10 Flutter Build Errors | If you hit errors after adding the http package, the build errors guide has the most common fixes. |
