Flutter FutureBuilder: Handling Async Data in Your UI
If you've ever needed to fetch data from an API, read a file, or perform any operation that takes time, you've encountered the challenge of displaying async data in Flutter. Enter FutureBuilder – your friendly helper for bridging the gap between asynchronous operations and your widget tree.
In this article, we'll explore how FutureBuilder works, when to use it, common pitfalls to avoid, and best practices that will make your async UI code cleaner and more reliable.
What is a Future?
Before diving into FutureBuilder, let's quickly understand what a Future is. A Future represents a value that will be available at some point in the future. Think of it like ordering food at a restaurant – you place your order (start the Future), wait for it to be prepared (the async operation), and eventually receive your meal (the result).
Future fetchUserName() async {
// Simulate network delay
await Future.delayed(Duration(seconds: 2));
return 'Alice';
}
A Future can complete with a value (success) or with an error (failure). This is important because FutureBuilder needs to handle both cases gracefully.
Understanding FutureBuilder
FutureBuilder is a widget that builds itself based on the latest snapshot of a Future. It automatically rebuilds when the Future completes, allowing you to show different UI states like loading indicators, data, or error messages.
Here's the basic structure:
FutureBuilder(
future: fetchUserName(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (snapshot.hasData) {
return Text('Hello, ${snapshot.data}!');
}
return Text('No data');
},
)
The AsyncSnapshot Object
The builder function receives an AsyncSnapshot object that contains all the information you need about the current state of the Future. Let's explore its key properties:
- connectionState – Tells you whether the Future is waiting, active, or done
- hasData – Returns true if the snapshot contains non-null data
- hasError – Returns true if the snapshot contains an error
- data – The data returned by the Future (if successful)
- error – The error thrown by the Future (if failed)
Connection States Explained
The ConnectionState enum has four values, but for FutureBuilder, you'll primarily work with two:
FutureBuilder(
future: fetchUser(),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
// No Future has been provided
return Text('Press button to load');
case ConnectionState.waiting:
// Future is running
return Center(child: CircularProgressIndicator());
case ConnectionState.active:
// For Streams only, not typically used with Futures
return Text('Active...');
case ConnectionState.done:
// Future completed (success or error)
if (snapshot.hasError) {
return ErrorWidget(error: snapshot.error!);
}
return UserProfile(user: snapshot.data!);
}
},
)
A Complete Practical Example
Let's build a realistic example – a screen that fetches and displays a list of posts from an API:
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
class Post {
final int id;
final String title;
final String body;
Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
class PostService {
static const String _tag = 'PostService';
Future> fetchPosts() async {
debugPrint('$_tag.fetchPosts: Starting to fetch posts');
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
debugPrint('$_tag.fetchPosts: Response status ${response.statusCode}');
if (response.statusCode == 200) {
final List jsonList = json.decode(response.body);
debugPrint('$_tag.fetchPosts: Parsed ${jsonList.length} posts');
return jsonList.map((json) => Post.fromJson(json)).toList();
} else {
debugPrint('$_tag.fetchPosts: Failed with status ${response.statusCode}');
throw Exception('Failed to load posts');
}
}
}
Now let's create the widget that uses FutureBuilder:
class PostsScreen extends StatefulWidget {
const PostsScreen({super.key});
@override
State createState() => _PostsScreenState();
}
class _PostsScreenState extends State {
static const String _tag = '_PostsScreenState';
late Future> _postsFuture;
final PostService _postService = PostService();
@override
void initState() {
super.initState();
debugPrint('$_tag.initState: Initializing posts future');
_postsFuture = _postService.fetchPosts();
}
void _refreshPosts() {
debugPrint('$_tag._refreshPosts: Refreshing posts');
setState(() {
_postsFuture = _postService.fetchPosts();
});
}
@override
Widget build(BuildContext context) {
debugPrint('$_tag.build: Building widget');
return Scaffold(
appBar: AppBar(
title: Text('Posts'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: _refreshPosts,
),
],
),
body: FutureBuilder>(
future: _postsFuture,
builder: (context, snapshot) {
debugPrint('$_tag.build: FutureBuilder state: ${snapshot.connectionState}');
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
debugPrint('$_tag.build: Error occurred: ${snapshot.error}');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48),
SizedBox(height: 16),
Text('Something went wrong'),
SizedBox(height: 8),
ElevatedButton(
onPressed: _refreshPosts,
child: Text('Try Again'),
),
],
),
);
}
final posts = snapshot.data!;
debugPrint('$_tag.build: Displaying ${posts.length} posts');
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
);
},
),
);
}
}
Common Mistake: Creating Futures in Build Method
One of the most common mistakes with FutureBuilder is creating the Future directly in the build method. This causes the Future to be recreated every time the widget rebuilds, leading to infinite loops or unnecessary network calls.
Here's the wrong way:
// WRONG - Future is recreated on every build!
class BadExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: fetchData(), // Creates new Future every rebuild!
builder: (context, snapshot) {
// This will keep showing loading forever
return Text(snapshot.data ?? 'Loading...');
},
);
}
}
And here's the correct approach:
// CORRECT - Future is created once in initState
class GoodExample extends StatefulWidget {
@override
State createState() => _GoodExampleState();
}
class _GoodExampleState extends State {
static const String _tag = '_GoodExampleState';
late Future _dataFuture;
@override
void initState() {
super.initState();
debugPrint('$_tag.initState: Creating future once');
_dataFuture = fetchData();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _dataFuture, // Same Future instance
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Loading...');
},
);
}
}
Handling Initial Data
Sometimes you want to show cached or default data while waiting for fresh data. FutureBuilder's initialData parameter helps with this:
class CachedDataExample extends StatefulWidget {
@override
State createState() => _CachedDataExampleState();
}
class _CachedDataExampleState extends State {
static const String _tag = '_CachedDataExampleState';
late Future _dataFuture;
final CacheService _cache = CacheService();
@override
void initState() {
super.initState();
debugPrint('$_tag.initState: Setting up future with cache');
_dataFuture = fetchFreshData();
}
@override
Widget build(BuildContext context) {
final cachedData = _cache.getData();
debugPrint('$_tag.build: Cached data available: ${cachedData != null}');
return FutureBuilder(
future: _dataFuture,
initialData: cachedData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting &&
snapshot.hasData) {
// Show cached data with loading indicator
debugPrint('$_tag.build: Showing cached data while loading');
return Column(
children: [
LinearProgressIndicator(),
Text(snapshot.data!),
],
);
}
if (snapshot.hasError) {
debugPrint('$_tag.build: Error - ${snapshot.error}');
return Text('Error loading data');
}
return Text(snapshot.data ?? 'No data');
},
);
}
}
FutureBuilder vs StreamBuilder
It's important to understand when to use each:
- FutureBuilder – For one-time async operations (API calls, file reads, database queries)
- StreamBuilder – For continuous data streams (real-time updates, WebSockets, Firebase listeners)
// Use FutureBuilder for one-time fetch
FutureBuilder(
future: userRepository.getUser(userId),
builder: (context, snapshot) => UserCard(user: snapshot.data),
)
// Use StreamBuilder for real-time updates
StreamBuilder>(
stream: chatRepository.messagesStream(chatId),
builder: (context, snapshot) => MessageList(messages: snapshot.data),
)
Creating a Reusable Async Widget
To avoid repetitive code, you can create a reusable widget that handles all the common async states:
class AsyncBuilder extends StatelessWidget {
final Future future;
final Widget Function(T data) onData;
final Widget Function(Object error)? onError;
final Widget? onLoading;
const AsyncBuilder({
super.key,
required this.future,
required this.onData,
this.onError,
this.onLoading,
});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return onLoading ?? Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return onError?.call(snapshot.error!) ??
Center(child: Text('Error: ${snapshot.error}'));
}
if (snapshot.hasData) {
return onData(snapshot.data as T);
}
return SizedBox.shrink();
},
);
}
}
// Usage
AsyncBuilder>(
future: postService.fetchPosts(),
onData: (posts) => PostList(posts: posts),
onError: (error) => ErrorView(message: error.toString()),
onLoading: ShimmerPostList(),
)
Best Practices Summary
Let's wrap up with key best practices for using FutureBuilder effectively:
- Never create Futures in build() – Always create them in initState() or event handlers
- Handle all states – Always handle waiting, error, and success states
- Use initialData wisely – Show cached data for better UX while loading fresh data
- Add proper logging – Debug statements help track async flow issues
- Consider user experience – Show meaningful loading states and error messages
- Type your FutureBuilder – Always specify the generic type for better type safety
// Complete best practices example
class BestPracticesExample extends StatefulWidget {
const BestPracticesExample({super.key});
@override
State createState() => _BestPracticesExampleState();
}
class _BestPracticesExampleState extends State {
static const String _tag = '_BestPracticesExampleState';
late Future _profileFuture;
final UserService _userService = UserService();
final CacheService _cache = CacheService();
@override
void initState() {
super.initState();
debugPrint('$_tag.initState: Initializing profile future');
_profileFuture = _loadProfile();
}
Future _loadProfile() async {
debugPrint('$_tag._loadProfile: Starting profile load');
try {
final profile = await _userService.getCurrentUserProfile();
await _cache.saveProfile(profile);
debugPrint('$_tag._loadProfile: Profile loaded and cached');
return profile;
} catch (e) {
debugPrint('$_tag._loadProfile: Error loading profile - $e');
rethrow;
}
}
void _retry() {
debugPrint('$_tag._retry: Retrying profile load');
setState(() {
_profileFuture = _loadProfile();
});
}
@override
Widget build(BuildContext context) {
debugPrint('$_tag.build: Building widget');
return FutureBuilder(
future: _profileFuture,
initialData: _cache.getCachedProfile(),
builder: (context, snapshot) {
debugPrint('$_tag.build: Connection state: ${snapshot.connectionState}');
// Show loading indicator
if (snapshot.connectionState == ConnectionState.waiting) {
if (snapshot.hasData) {
// Show cached data with refresh indicator
return Stack(
children: [
ProfileView(profile: snapshot.data!),
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
],
);
}
return Center(child: CircularProgressIndicator());
}
// Handle error state
if (snapshot.hasError) {
debugPrint('$_tag.build: Error - ${snapshot.error}');
return ErrorView(
message: 'Could not load profile',
onRetry: _retry,
);
}
// Show data
if (snapshot.hasData) {
return ProfileView(profile: snapshot.data!);
}
return Center(child: Text('No profile data'));
},
);
}
}
Conclusion
FutureBuilder is an essential tool in every Flutter developer's toolkit. By understanding how it works and following best practices, you can create smooth, responsive UIs that handle async operations gracefully. Remember to always create your Futures outside the build method, handle all connection states, and provide meaningful feedback to users during loading and error states.
The key takeaway is that FutureBuilder bridges the gap between your async data and your widget tree, making it easy to show the right UI at the right time. Combined with proper error handling and loading states, your apps will feel polished and professional.
Happy coding!
Advanced Learning: Understanding the FutureBuilder Lifecycle
For developers looking to deepen their understanding, here's what happens under the hood when FutureBuilder builds:
- When the widget first builds, it subscribes to the Future using
.then()and.catchError() - The initial snapshot has
ConnectionState.waiting(or contains initialData if provided) - When the Future completes, Flutter calls
setState()internally to trigger a rebuild - The new snapshot contains either
dataorerrorwithConnectionState.done - If the Future reference changes (different object), FutureBuilder resubscribes to the new Future
Understanding this lifecycle helps you debug issues and optimize performance. For instance, knowing that changing the Future reference triggers a resubscription explains why creating Futures in build() causes infinite loops – each rebuild creates a new Future, which triggers a new subscription, which causes another rebuild when it completes.