Using Flutter's FutureBuilder to Load Remote Data Without Extra State
Learn how Flutter's FutureBuilder ties UI to a Future’s lifecycle, avoiding duplicate network calls and manual state flags, with a concrete JSON placeholder example and tips to prevent common pitfalls.
11 Sept 2025, 11:20 UTC

Problem: UI flickers or makes duplicate network calls when showing async data
When you need to display data fetched from a REST API, a common mistake is to call the fetch function directly inside the build method. Every time Flutter rebuilds the widget (e.g., on an orientation change or a parent setState), the function runs again, launching a new HTTP request. This leads to unnecessary bandwidth usage, CPU work, and sometimes a brief flash of stale or missing data.
Takeaway: FutureBuilder lets you declaratively tie UI to a Future’s lifecycle, eliminating manual state flags and reducing redundant work—provided the Future object survives across rebuilds.
How FutureBuilder works
The widget receives two required arguments:
Future<T> future– the asynchronous computation you want to observe.AsyncWidgetBuilder<T> builder– a function called with anAsyncSnapshot<T>that tells you the connection state, whether data is present, and if an error occurred.
FutureBuilder internally registers a listener on the Future. Whenever the Future transitions between waiting, active, or done, it calls builder again, giving you a chance to show a loading spinner, the result, or an error widget—all without managing setState yourself.
Worked example: fetching a JSON placeholder post
First, add the http package to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
Run flutter pub get in the project root.
Replace the default lib/main.dart with the following minimal app:
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('FutureBuilder Demo')),
body: const PostBody(),
),
);
}
}
class PostBody extends StatefulWidget {
const PostBody({super.key});
@override
State createState() => _PostBodyState();
}
class _PostBodyState extends State {
// The Future is created once in initState and stored in the State object.
late final Future _postFuture;
@override
void initState() {
super.initState();
_postFuture = _fetchPost();
}
Future _fetchPost() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts/1'),
);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
throw Exception('Failed to load post');
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Map<String, dynamic>>(
future: _postFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
// snapshot.hasData is true here
final post = snapshot.requireData;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(post['title'] as String,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(post['body'] as String),
],
),
);
},
);
}
}
Where to run: edit lib/main.dart in your Flutter project, then execute flutter run on an emulator or physical device. No special permissions are required beyond the standard internet permission that Flutter adds automatically for Android and iOS.
What you should observe:
- A spinning indicator while the request is in flight.
- Once the Future completes, the post’s title and body appear.
- To simulate an error, enable airplane mode or disconnect the network before tapping the app; the error widget will show.
Trade‑off: accidental Future recreation
The example above stores the Future in State (initState) so it survives across rebuilds. If you mistakenly move the Future creation into the build method, like this:
FutureBuilder<Map<String, dynamic>>(
future: _fetchPost(), // ← created every build
builder: (context, snapshot) { ... },
)
Each time setState is called (or the widget inherits a new ancestor), Flutter will invoke _fetchPost again, launching a duplicate HTTP request. This can waste bandwidth and cause UI flicker as the loading indicator reappears.
Practical way to check the difference:
- Run the version with the Future inside
build. - Press a button that calls
setState(() {})(you can add a simple FloatingActionButton for this). - Watch the network tab in DevTools or enable
Debug > Show Paint Baselinesto see the loading spinner reappear. - Now move the Future to
initStateas shown earlier and repeat the step; the spinner should appear only once.
Actionable closing
FutureBuilder is a lightweight, built‑in solution for UI‑driven async work when:
- You have a single Future whose result does not need complex mutation after it arrives.
- You can hoist the Future into a State object or a higher‑level widget (e.g., using
StatefulWidgetorRiverpod/Blocif you need more control).
If you find yourself needing to cancel, retry, or combine multiple Futures, consider pairing FutureBuilder with a state‑management package or using StreamBuilder for continuous data. For most simple fetch‑and‑display scenarios, the pattern demonstrated above gives you a clean UI with minimal boilerplate and no hidden extra network calls—just remember to keep the Future alive across rebuilds.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.