FutureBuilder and Async UI
Rendering loading, error, and success states directly from a Future, without manual setState bookkeeping.
阅读需 2 分钟
The previous lesson fetched data with http and printed the result. In a real app, that data needs to show up on screen — with a loading spinner while it's in flight and an error message if it fails. FutureBuilder is the widget built for exactly this.
The problem it solves
You could track this manually with setState: a boolean for loading, a variable for the error, a variable for the result, updated across three different places. FutureBuilder collapses all of that into one widget that rebuilds itself as a Future progresses through its states.
FutureBuilder<User>(
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final user = snapshot.data!;
return Center(child: Text('Hello, ${user.name}'));
},
)snapshot carries everything you need to know about the Future's current state: connectionState tells you whether it's still running, hasError/error surface a thrown exception, and data holds the result once it resolves successfully.
A critical gotcha: don't call the future inline
The example above has a subtle bug that only shows up once this widget is inside a StatefulWidget that rebuilds for other reasons: writing future: fetchUser() directly inside build calls fetchUser() again on every rebuild, re-triggering the network request each time — even if nothing relevant changed.
class UserProfile extends StatefulWidget {
const UserProfile({super.key});
@override
State<UserProfile> createState() => _UserProfileState();
}
class _UserProfileState extends State<UserProfile> {
late final Future<User> _userFuture;
@override
void initState() {
super.initState();
_userFuture = fetchUser(); // called once, not on every build
}
@override
Widget build(BuildContext context) {
return FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
return Center(child: Text('Hello, ${snapshot.data!.name}'));
},
);
}
}initState runs exactly once, when the State object is first created — the right place to kick off a Future that build will observe repeatedly without restarting it.
Handling the "no data yet" edge case
connectionState can technically be done while hasData is still false, if the future completed with null. Checking hasData explicitly, rather than assuming done means you have a usable value, avoids a null-check crash on snapshot.data!:
if (!snapshot.hasData) {
return const Center(child: Text('No data available'));
}StreamBuilder for ongoing data
FutureBuilder fits a one-time request that resolves once. For data that arrives repeatedly over time — a live chat, a real-time price feed — StreamBuilder follows the same snapshot-based pattern but rebuilds every time the underlying Stream emits a new value, rather than just once. Reaching for the right one of the two comes down to a simple question: does this data arrive once, or does it keep arriving?