Why async matters in Flutter UI
Many real apps depend on work that finishes later: network requests, reading from disk, or heavy computations. In Dart, these operations are typically represented by a Future, which is a value that will be available at some point in the future (or will fail with an error). Flutter’s UI must stay responsive while waiting, so you start the work asynchronously and render different UI states: loading, success, and error.
Futures in one sentence
A Future<T> is a placeholder for a value of type T that will arrive later, or an error if the operation fails.
async/await: writing asynchronous code that reads like synchronous code
Dart provides async and await to make Future-based code easier to read.
- Mark a function async when it returns a Future and uses await inside.
- Use await to pause within that async function until a Future completes.
- Use try/catch to handle errors from awaited futures.
Future<int> fetchNumber() async { await Future.delayed(const Duration(seconds: 1)); return 42;}Future<void> demo() async { try { final value = await fetchNumber(); // use value } catch (e) { // handle error }}Where async work should live: initState vs build
build() can run many times (theme changes, parent rebuilds, setState, etc.). Starting a network request inside build risks triggering repeated calls and flickering UI.
- Start one-time async work in initState() and store the Future in a field.
- Use FutureBuilder in build to render based on that stored Future.
- Trigger a new request intentionally (for example, pull-to-refresh or a retry button) by assigning a new Future inside setState.
Rendering async data with FutureBuilder
FutureBuilder<T> listens to a Future and rebuilds when its state changes. It provides an AsyncSnapshot<T> that tells you whether the Future is loading, completed with data, or completed with an error.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
- ConnectionState.waiting: show a loading indicator.
- snapshot.hasError: show an error UI.
- snapshot.hasData: show the data UI.
Practical example part 1: simulate a network call
Before hitting a real API, simulate latency and errors. This helps you practice loading and error states without external dependencies.
Step 1: create a fake service
class FakeApiService { Future<List<String>> fetchItems() async { await Future.delayed(const Duration(seconds: 2)); // Toggle this to test error state: // throw Exception('Simulated network error'); return ['Alpha', 'Beta', 'Gamma', 'Delta']; }}Step 2: build a screen that uses FutureBuilder correctly
Notice how the Future is created once in initState and reused in build.
import 'package:flutter/material.dart';class FakeFetchScreen extends StatefulWidget { const FakeFetchScreen({super.key}); @override State<FakeFetchScreen> createState() => _FakeFetchScreenState();}class _FakeFetchScreenState extends State<FakeFetchScreen> { final _service = FakeApiService(); late Future<List<String>> _itemsFuture; @override void initState() { super.initState(); _itemsFuture = _service.fetchItems(); } void _retry() { setState(() { _itemsFuture = _service.fetchItems(); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Simulated Fetch')), body: FutureBuilder<List<String>>( future: _itemsFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Error: ${snapshot.error}'), const SizedBox(height: 12), ElevatedButton( onPressed: _retry, child: const Text('Retry'), ), ], ), ); } final items = snapshot.data ?? []; if (items.isEmpty) { return const Center(child: Text('No items found.')); } return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { return ListTile(title: Text(items[index])); }, ); }, ), ); }}Practical example part 2: fetch real data from a public API
Next, fetch data from a real endpoint, parse JSON into Dart models, and render it in a list. A common beginner-friendly public API is JSONPlaceholder.
Step 1: add an HTTP client package
Add the http package to your project dependencies, then run package get.
dependencies: http: ^1.2.2Step 2: create a Dart model for the JSON
We’ll fetch posts from https://jsonplaceholder.typicode.com/posts. Each post has fields like id, title, and body. Create a model with a factory constructor to parse JSON maps.
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<String, dynamic> json) { return Post( id: json['id'] as int, title: json['title'] as String, body: json['body'] as String, ); }}Step 3: create an API service using the http package
This service performs the GET request, checks the status code, decodes JSON, and maps it into a list of Post objects.
import 'dart:convert';import 'package:http/http.dart' as http;class PostsApi { final http.Client _client; PostsApi({http.Client? client}) : _client = client ?? http.Client(); Future<List<Post>> fetchPosts() async { final uri = Uri.parse('https://jsonplaceholder.typicode.com/posts'); final response = await _client.get(uri); if (response.statusCode != 200) { throw Exception('Request failed: ${response.statusCode}'); } final decoded = jsonDecode(response.body); if (decoded is! List) { throw Exception('Unexpected JSON shape'); } return decoded .map((e) => Post.fromJson(e as Map<String, dynamic>)) .toList(); }}Step 4: display results in a list with loading and error states
This screen follows the same pattern: create the Future once in initState, render with FutureBuilder, and provide a retry path.
import 'package:flutter/material.dart';class PostsScreen extends StatefulWidget { const PostsScreen({super.key}); @override State<PostsScreen> createState() => _PostsScreenState();}class _PostsScreenState extends State<PostsScreen> { final _api = PostsApi(); late Future<List<Post>> _postsFuture; @override void initState() { super.initState(); _postsFuture = _api.fetchPosts(); } void _retry() { setState(() { _postsFuture = _api.fetchPosts(); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Posts')), body: FutureBuilder<List<Post>>( future: _postsFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Could not load posts.'), const SizedBox(height: 8), Text('${snapshot.error}'), const SizedBox(height: 12), ElevatedButton( onPressed: _retry, child: const Text('Try again'), ), ], ), ), ); } final posts = snapshot.data ?? []; if (posts.isEmpty) { return const Center(child: Text('No posts available.')); } return ListView.separated( itemCount: posts.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final post = posts[index]; return ListTile( title: Text( post.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( post.body, maxLines: 2, overflow: TextOverflow.ellipsis, ), leading: CircleAvatar(child: Text('${post.id}')), ); }, ); }, ), ); }}Common pitfalls and practical fixes
Accidentally refetching on every rebuild
If you write future: _api.fetchPosts() directly inside FutureBuilder, a rebuild can create a new Future and trigger another request. Prefer storing the Future in state (initState) and passing that field to FutureBuilder.
Not handling empty data
Even successful calls can return an empty list. Treat “empty” as its own UI state so users aren’t staring at a blank screen.
Forgetting error UI
Network requests fail in real life. Always render an error message and provide a retry action that assigns a new Future via setState.
Parsing issues
JSON parsing errors are common when the API shape changes. Defensive checks (like verifying the decoded JSON is a List) help you fail with a clear error rather than a confusing type cast exception.