Flutter for Beginners: Async Programming and Simple Data Fetching

Capítulo 11

Estimated reading time: 6 minutes

+ Exercise

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.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

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.2

Step 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.

Now answer the exercise about the content:

In a StatefulWidget that fetches data with FutureBuilder, what is the recommended way to avoid triggering repeated network requests during rebuilds?

You are right! Congratulations, now go to the next page

You missed! Try again.

build() can run many times, so creating the Future there can refetch repeatedly. Store the Future created in initState() and use it in FutureBuilder, only assigning a new Future in setState() for retries/refresh.

Next chapter

Flutter for Beginners: Building a Functional Cross-Platform Mini App

Arrow Right Icon
Free Ebook cover Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch
92%

Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch

New course

12 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.