Flutter Bloc Pattern 9.1.1 Call API

Flutter Architecture Tutorial

How to Fetch API Data & Display in ListView Using Flutter BLoC Pattern

Build a clean Flutter app that fetches users from a REST API, manages loading and error states with BLoC, and displays results in a ListView.

Fetching data from a REST API and displaying it in a scrolling list is one of the most common tasks in Flutter development. As an app grows, keeping network requests, UI code, and state management mixed together can make the code difficult to test and maintain.

The BLoC (Business Logic Component) pattern helps separate business logic from the presentation layer. In this tutorial, we’ll build a small example using flutter_bloc, http, and a public sample API. You’ll learn how to handle loading, success, empty, and error states without placing API logic directly inside the widget.

What we’ll build

  • Fetch a list of users from a REST endpoint
  • Create a model, repository, BLoC, events, and states
  • Show a progress indicator while data loads
  • Render API results in ListView.builder
  • Display an error message and provide a retry button
  • Keep networking and UI concerns separate for easier testing

1. Understand the BLoC data flow

In this example, the UI sends an event to the BLoC. The BLoC asks the repository for data, then emits a state that describes what the UI should show. The widget rebuilds based on that state.

UI / UserListPage
↓ dispatches event · ↑ renders state
UserBloc
↓ requests data
UserRepository
↓ HTTP request
REST API

The key idea is that the widget does not need to know how the HTTP request works. It only needs to respond to states such as loading, loaded, and failure.

2. Add the required packages

From your Flutter project directory, run:

flutter pub add flutter_bloc http

This tutorial uses:

  • flutter_bloc to connect BLoC state management with Flutter widgets.
  • http to send the REST request.

The sample endpoint is https://jsonplaceholder.typicode.com/users. It is a public demo API, useful for learning but not intended as a production backend. For a real application, use your own API endpoint and follow its authentication, pagination, and error-handling requirements.

3. Create a clean project structure

For a small tutorial, you can use this structure. In a larger application, organize files by feature or layer according to your team's conventions.

lib/
  main.dart
  models/
    user.dart
  repositories/
    user_repository.dart
  bloc/
    user_event.dart
    user_state.dart
    user_bloc.dart
  pages/
    user_list_page.dart

Each file has one main responsibility: model the response, fetch data, represent events and states, coordinate the request, or render the screen.

4. Create the User model

JSON from the endpoint includes fields such as id, name, username, and email. A model converts each JSON object into a typed Dart object.

Create lib/models/user.dart:

class User {
  const User({
    required this.id,
    required this.name,
    required this.username,
    required this.email,
  });

  final int id;
  final String name;
  final String username;
  final String email;

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      username: json['username'] as String,
      email: json['email'] as String,
    );
  }
}

Using a model gives the rest of the app predictable types and avoids passing loosely typed JSON maps through the UI.

5. Create the repository to fetch API data

The repository is responsible for the HTTP request, checking the response status, decoding JSON, and converting it to a list of User objects. Keeping this logic outside the BLoC makes it easier to test and replace later.

Create lib/repositories/user_repository.dart:

import 'dart:convert';

import 'package:http/http.dart' as http;

import '../models/user.dart';

class UserRepository {
  UserRepository({http.Client? client})
      : _client = client ?? http.Client();

  final http.Client _client;

  Future<List<User>> fetchUsers() async {
    final uri = Uri.parse(
      'https://jsonplaceholder.typicode.com/users',
    );

    final response = await _client.get(uri);

    if (response.statusCode != 200) {
      throw Exception(
        'Failed to load users (HTTP ${response.statusCode})',
      );
    }

    final decoded = jsonDecode(response.body) as List<dynamic>;

    return decoded
        .map(
          (item) => User.fromJson(
            item as Map<String, dynamic>,
          ),
        )
        .toList();
  }

  void dispose() {
    _client.close();
  }
}

Injecting http.Client allows a test to supply a controlled client rather than making a real network request. This repository owns the client it creates by default; if you pass a shared client, adjust ownership and disposal to match your app's dependency-injection setup.

Production tip: consider explicit timeouts, typed exceptions, logging that avoids sensitive data, authentication headers where required, and response validation. Do not put API secrets in a Flutter app; client-side values can be extracted from the application package.

6. Define the BLoC events

An event describes something that happened or something the user requested. For this screen, we need an event to load the users and an event to retry after a failure.

Create lib/bloc/user_event.dart:

abstract class UserEvent {}

class UsersRequested extends UserEvent {}

class UsersRetried extends UserEvent {}

These event classes are intentionally simple. You could also use one LoadUsers event for both initial loading and retrying. Separate event names can make user intent more explicit, but they are not required for BLoC to work.

7. Define the BLoC states

The UI needs to know whether the request is in progress, completed successfully, or failed. We’ll also represent an empty result separately so the user gets a useful message when the API returns no users.

Create lib/bloc/user_state.dart:

import '../models/user.dart';

abstract class UserState {}

class UsersInitial extends UserState {}

class UsersLoading extends UserState {}

class UsersLoaded extends UserState {
  UsersLoaded(this.users);

  final List<User> users;
}

class UsersEmpty extends UserState {}

class UsersFailure extends UserState {
  UsersFailure(this.message);

  final String message;
}

For larger projects, immutable state classes and value equality (for example, with Equatable or generated models) can reduce boilerplate and make state comparisons easier to reason about.

8. Implement UserBloc

The BLoC receives events, emits a loading state, asks the repository for users, and then emits a loaded, empty, or failure state. This is where the asynchronous business flow is coordinated.

Create lib/bloc/user_bloc.dart:

import 'package:flutter_bloc/flutter_bloc.dart';

import '../repositories/user_repository.dart';
import 'user_event.dart';
import 'user_state.dart';

class UserBloc extends Bloc<UserEvent, UserState> {
  UserBloc(this._repository) : super(UsersInitial()) {
    on<UsersRequested>((event, emit) async {
      await _loadUsers(emit);
    });

    on<UsersRetried>((event, emit) async {
      await _loadUsers(emit);
    });
  }

  final UserRepository _repository;

  Future<void> _loadUsers(Emitter<UserState> emit) async {
    emit(UsersLoading());

    try {
      final users = await _repository.fetchUsers();

      if (users.isEmpty) {
        emit(UsersEmpty());
      } else {
        emit(UsersLoaded(users));
      }
    } catch (error) {
      emit(UsersFailure('Unable to load users. Please try again.'));
    }
  }
}

The UI receives a friendly error message rather than a raw exception. In production, you may log a sanitized technical error for diagnostics while showing users a concise message. Avoid exposing server internals or sensitive response data in the UI.

9. Build the ListView UI

Now create the page. BlocProvider makes the BLoC available to the widget subtree, while BlocBuilder rebuilds the UI when the state changes. ListView.builder builds list rows on demand, making it a good fit for scrolling collections.

Create lib/pages/user_list_page.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../bloc/user_bloc.dart';
import '../bloc/user_event.dart';
import '../bloc/user_state.dart';
import '../models/user.dart';
import '../repositories/user_repository.dart';

class UserListPage extends StatelessWidget {
  const UserListPage({
    super.key,
    required this.repository,
  });

  final UserRepository repository;

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => UserBloc(repository)..add(UsersRequested()),
      child: const _UserListView(),
    );
  }
}

class _UserListView extends StatelessWidget {
  const _UserListView();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: BlocBuilder<UserBloc, UserState>(
        builder: (context, state) {
          if (state is UsersInitial || state is UsersLoading) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          if (state is UsersFailure) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      state.message,
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 12),
                    ElevatedButton(
                      onPressed: () {
                        context.read<UserBloc>().add(UsersRetried());
                      },
                      child: const Text('Retry'),
                    ),
                  ],
                ),
              ),
            );
          }

          if (state is UsersEmpty) {
            return const Center(
              child: Text('No users found.'),
            );
          }

          if (state is UsersLoaded) {
            return _buildUserList(state.users);
          }

          return const SizedBox.shrink();
        },
      ),
    );
  }

  Widget _buildUserList(List<User> users) {
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) {
        final user = users[index];

        return ListTile(
          leading: CircleAvatar(
            child: Text(user.name.isNotEmpty
                ? user.name[0].toUpperCase()
                : '?'),
          ),
          title: Text(user.name),
          subtitle: Text(user.email),
          trailing: const Icon(Icons.chevron_right),
        );
      },
    );
  }
}

The UI handles each state explicitly:

  • Initial/loading: displays a progress indicator.
  • Loaded: displays users with ListView.builder.
  • Empty: explains that no results were returned.
  • Failure: displays a friendly message and a retry button.

10. Connect everything in main.dart

Finally, create the repository and pass it to the page. Create lib/main.dart:

import 'package:flutter/material.dart';

import 'pages/user_list_page.dart';
import 'repositories/user_repository.dart';

void main() {
  final repository = UserRepository();

  runApp(MyApp(repository: repository));
}

class MyApp extends StatelessWidget {
  const MyApp({
    super.key,
    required this.repository,
  });

  final UserRepository repository;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter BLoC API List',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigo,
        ),
        useMaterial3: true,
      ),
      home: UserListPage(repository: repository),
    );
  }
}

This minimal example creates the repository once and reuses it. In a production app, use your app’s dependency-injection or lifecycle approach to manage long-lived clients and dispose of resources at the correct time. If your app owns the repository for the full application lifetime, close its HTTP client during application shutdown using an appropriate lifecycle owner.

11. Run the app

Start an emulator, simulator, or connected device, then run:

flutter pub get
flutter analyze
flutter run

When the page opens, the app requests the sample users. You should see a loading indicator first, followed by a scrollable list containing names and email addresses. If the request fails, the retry action sends another event to the BLoC.

On Android, verify that your app manifest includes the internet permission for network access. The standard Flutter project often includes it, but check android/app/src/main/AndroidManifest.xml if requests fail in a built app:

<uses-permission android:name="android.permission.INTERNET" />

Place this permission directly under the manifest element, outside the application element. For iOS, use HTTPS endpoints and review platform networking requirements if a request fails.

12. Improve the implementation for production

  • Use typed failures: distinguish network errors, unauthorized responses, server errors, and malformed JSON where that distinction helps the UI or logging.
  • Add pagination: for large datasets, request pages and append results instead of downloading everything at once.
  • Prevent duplicate requests: consider event transformers or explicit request coordination if rapid repeated events can trigger redundant work.
  • Keep state immutable: avoid modifying a list after emitting it. Create a new list when data changes.
  • Support pull-to-refresh: use a refresh control that dispatches an event and correctly reflects loading state.
  • Test without the network: inject a fake repository into BLoC tests so success, empty, and failure states are deterministic.
  • Handle lifecycle and disposal: close owned HTTP clients and other resources according to your dependency ownership model.
  • Protect sensitive data: never log access tokens or personal information; do not embed private server secrets in the app.

13. Common issues and how to troubleshoot them

IssueWhat to check
List stays on loadingCheck the request, internet access, repository exception handling, and whether the BLoC emits a terminal state.
JSON parsing exceptionInspect the API response shape and ensure fields match the model's expected types.
BLoC not found in contextEnsure the widget is below the correct BlocProvider in the widget tree.
Retry does nothingVerify that the button dispatches the retry event and the BLoC has a matching event handler.
Network works on one platform onlyCheck Android permissions, HTTPS configuration, emulator connectivity, and platform-specific networking rules.

14. Quick recap

✓ The model converts JSON into typed Dart objects.

✓ The repository makes the HTTP request and parses the response.

✓ Events describe what the user or app wants to do.

✓ The BLoC coordinates work and emits UI states.

✓ BlocBuilder renders loading, success, empty, and error states.

✓ ListView.builder displays each result efficiently as a scrolling list.

Conclusion

Using BLoC to fetch API data and display it in a Flutter ListView creates a clear separation between networking, business logic, and presentation. The repository handles HTTP and JSON, the BLoC coordinates events and states, and the UI reacts to each state.

Once this foundation is in place, you can extend it with search, pagination, pull-to-refresh, authentication, caching, and automated tests without putting all the logic inside a single widget.

Comments