flutter
/

BLoC with Dio: Robust API Integration & Error Handling

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC with Dio: Robust API Integration & Error Handling

Introduction

Dio is a powerful HTTP client for Dart that supports interceptors, global configuration, FormData, and cancellation. When combined with BLoC, it forms a robust network layer for your Flutter apps. This guide covers how to integrate Dio with BLoC using the repository pattern, manage interceptors for authentication and logging, cancel in‑flight requests, handle errors gracefully, and test your network logic.

Why Use Dio with BLoC?

  • Interceptor Support – Add headers, handle tokens, log requests globally.
  • Request Cancellation – Cancel ongoing requests (e.g., when user navigates away).
  • FormData & File Uploads – Easy multipart file uploads with progress.
  • Flexible Configuration – Base URL, timeouts, response interceptors.
  • Error Handling – Convert Dio errors to domain‑specific exceptions.

Setting Up Dio

Add Dio and other dependencies to your pubspec.yaml:

YAMLRead-only
1
dependencies:
  dio: ^5.4.0
  flutter_bloc: ^8.1.5
  equatable: ^2.0.5

# For testing
dev_dependencies:
  mocktail: ^1.0.0
  bloc_test: ^9.1.5

Create a Dio client with base configuration. It's often provided as a singleton or through dependency injection.

DARTRead-only
1
class DioClient {
  late final Dio _dio;

  DioClient() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.example.com',
      connectTimeout: const Duration(seconds: 30),
      receiveTimeout: const Duration(seconds: 30),
      headers: {
        'Content-Type': 'application/json',
      },
    ));
    _setupInterceptors();
  }

  void _setupInterceptors() {
    _dio.interceptors.add(InterceptorsWrapper(
      onRequest: (options, handler) {
        // Add auth token if available
        final token = AuthService.token;
        if (token != null) {
          options.headers['Authorization'] = 'Bearer $token';
        }
        return handler.next(options);
      },
      onResponse: (response, handler) => handler.next(response),
      onError: (error, handler) {
        // Global error handling (e.g., 401 -> logout)
        if (error.response?.statusCode == 401) {
          // Handle unauthorized
        }
        return handler.next(error);
      },
    ));

    // Add logger interceptor in debug mode
    if (kDebugMode) {
      _dio.interceptors.add(LogInterceptor(
        requestBody: true,
        responseBody: true,
      ));
    }
  }

  Dio get dio => _dio;
}

Repository Pattern

The repository pattern separates data sources (network, cache) from business logic. Your BLoC interacts with the repository, which uses Dio to fetch data.

DARTRead-only
1
class PostRepository {
  final DioClient dioClient;
  final CancelToken _cancelToken = CancelToken();

  PostRepository(this.dioClient);

  Future<List<Post>> fetchPosts() async {
    try {
      final response = await dioClient.dio.get('/posts', cancelToken: _cancelToken);
      final List<dynamic> data = response.data;
      return data.map((json) => Post.fromJson(json)).toList();
    } on DioException catch (e) {
      throw _handleError(e);
    }
  }

  void cancelRequests() {
    _cancelToken.cancel('Request cancelled by user');
  }

  Exception _handleError(DioException error) {
    if (error.type == DioExceptionType.connectionTimeout ||
        error.type == DioExceptionType.receiveTimeout) {
      return NetworkException('Connection timeout');
    } else if (error.type == DioExceptionType.cancel) {
      return CancelException('Request cancelled');
    } else if (error.response?.statusCode == 401) {
      return AuthException('Unauthorized');
    } else {
      return ServerException(error.message ?? 'Unknown error');
    }
  }
}

BLoC with Repository

The BLoC uses the repository to perform network operations and emits states. Use Equatable for state equality.

DARTRead-only
1
// States
@immutable
abstract class PostsState extends Equatable {}

class PostsInitial extends PostsState {
  @override
  List<Object?> get props => [];
}

class PostsLoading extends PostsState {
  @override
  List<Object?> get props => [];
}

class PostsLoaded extends PostsState {
  final List<Post> posts;
  const PostsLoaded(this.posts);

  @override
  List<Object?> get props => [posts];
}

class PostsError extends PostsState {
  final String message;
  const PostsError(this.message);

  @override
  List<Object?> get props => [message];
}

// Events
@immutable
abstract class PostsEvent extends Equatable {}

class FetchPosts extends PostsEvent {
  @override
  List<Object?> get props => [];
}

class CancelFetch extends PostsEvent {
  @override
  List<Object?> get props => [];
}

// BLoC
class PostsBloc extends Bloc<PostsEvent, PostsState> {
  final PostRepository repository;

  PostsBloc(this.repository) : super(PostsInitial()) {
    on<FetchPosts>(_onFetchPosts);
    on<CancelFetch>(_onCancelFetch);
  }

  Future<void> _onFetchPosts(FetchPosts event, Emitter<PostsState> emit) async {
    emit(PostsLoading());
    try {
      final posts = await repository.fetchPosts();
      emit(PostsLoaded(posts));
    } catch (e) {
      emit(PostsError(e.toString()));
    }
  }

  void _onCancelFetch(CancelFetch event, Emitter<PostsState> emit) {
    repository.cancelRequests();
    emit(PostsError('Request cancelled'));
  }

  @override
  Future<void> close() {
    repository.cancelRequests();
    return super.close();
  }
}

Handling Request Cancellation

Cancelling requests is important when users navigate away or when multiple requests race. Use CancelToken in Dio. In the BLoC, you can cancel requests on close or when a new request arrives (restartable pattern).

DARTRead-only
1
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  final SearchRepository repository;
  CancelToken? _cancelToken;

  SearchBloc(this.repository) : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearch,
      transformer: restartable(), // cancel previous when new query arrives
    );
  }

  Future<void> _onSearch(SearchQueryChanged event, Emitter<SearchState> emit) async {
    // Cancel previous request
    _cancelToken?.cancel('New search started');
    _cancelToken = CancelToken();

    emit(SearchLoading());
    try {
      final results = await repository.search(event.query, cancelToken: _cancelToken!);
      emit(SearchSuccess(results));
    } on DioException catch (e) {
      if (CancelToken.isCancel(e)) {
        // Ignore cancellation
        return;
      }
      emit(SearchError(e.message ?? 'Search failed'));
    }
  }

  @override
  Future<void> close() {
    _cancelToken?.cancel('Bloc closed');
    return super.close();
  }
}

Advanced: Interceptors for Authentication

Dio interceptors can automatically refresh expired tokens. This example shows how to intercept 401 errors and retry after refreshing the token.

DARTRead-only
1
class AuthInterceptor extends Interceptor {
  final Dio _dio;
  final RefreshTokenRepository _refreshRepo;
  bool _isRefreshing = false;
  final List<RequestOptions> _pendingRequests = [];

  AuthInterceptor(this._dio, this._refreshRepo);

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401 && !_isRefreshing) {
      _isRefreshing = true;
      try {
        final newToken = await _refreshRepo.refreshToken();
        // Save new token
        await AuthService.saveToken(newToken);
        // Retry all pending requests
        for (var request in _pendingRequests) {
          request.headers['Authorization'] = 'Bearer $newToken';
          _dio.fetch(request);
        }
        _pendingRequests.clear();
        // Retry the original request
        final opts = err.requestOptions;
        opts.headers['Authorization'] = 'Bearer $newToken';
        final response = await _dio.fetch(opts);
        handler.resolve(response);
      } catch (_) {
        // Refresh failed, logout
        handler.next(err);
      } finally {
        _isRefreshing = false;
      }
    } else {
      handler.next(err);
    }
  }

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // Add token from storage
    final token = AuthService.token;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}

Testing BLoC with Mocked Dio

To test BLoCs that use Dio, you can mock the repository or use MockClient from http_mock_adapter (for Dio). Mocktail can be used to mock the repository.

DARTRead-only
1
class MockPostRepository extends Mock implements PostRepository {}

void main() {
  late MockPostRepository mockRepository;
  late PostsBloc bloc;

  setUp(() {
    mockRepository = MockPostRepository();
    bloc = PostsBloc(mockRepository);
  });

  group('PostsBloc', () {
    blocTest<PostsBloc, PostsState>(
      'emits [PostsLoading, PostsLoaded] when fetch succeeds',
      build: () => bloc,
      setUp: () {
        when(() => mockRepository.fetchPosts()).thenAnswer((_) async => [Post(id: 1, title: 'Test')]);
      },
      act: (bloc) => bloc.add(FetchPosts()),
      expect: () => [
        PostsLoading(),
        PostsLoaded([Post(id: 1, title: 'Test')]),
      ],
    );

    blocTest<PostsBloc, PostsState>(
      'emits [PostsLoading, PostsError] when fetch fails',
      build: () => bloc,
      setUp: () {
        when(() => mockRepository.fetchPosts()).thenThrow(ServerException('Network error'));
      },
      act: (bloc) => bloc.add(FetchPosts()),
      expect: () => [
        PostsLoading(),
        PostsError('Network error'),
      ],
    );
  });
}

Best Practices

  • Use a single Dio instance – Share a configured Dio client via dependency injection (e.g., GetIt, Provider).
  • Separate network logic with repositories – Keep BLoC clean and testable.
  • Use CancelToken – Cancel requests when the BLoC is closed or when a new request overrides an old one.
  • Handle errors early – Convert Dio exceptions to domain‑specific exceptions in the repository.
  • Use interceptors for cross‑cutting concerns – Logging, authentication, token refresh.
  • Test with mocks – Avoid real network calls in unit tests; mock the repository or use an adapter.
  • Set timeouts – Prevent hanging requests with connectTimeout and receiveTimeout.

Common Mistakes

  • ❌ Not handling request cancellation – Leads to memory leaks and unnecessary processing. ✅ Cancel tokens in close and on new requests.
  • ❌ Creating multiple Dio instances – Overhead and loss of shared configuration. ✅ Use a singleton or service locator.
  • ❌ Ignoring error types – Throwing raw DioException makes BLoC handling messy. ✅ Map to custom exceptions.
  • ❌ Forgetting to set content‑type headers – May cause server errors. ✅ Set base headers in BaseOptions.
  • ❌ Not handling 401 globally – Leads to scattered authentication logic. ✅ Use an interceptor for token refresh.

Conclusion

Dio is a feature‑rich HTTP client that pairs excellently with BLoC. By organizing your network layer with repositories, using interceptors for cross‑cutting concerns, and managing request cancellation, you can build a robust, maintainable, and testable API integration. Following the patterns in this guide will ensure your BLoC remains focused on state management while Dio handles the complexities of HTTP communication.

Try it yourself

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

void main() => runApp(MyApp());

// Simple Cubit that uses Dio directly (demo only)
class PostCubit extends Cubit<List<dynamic>> {
  final Dio dio;
  PostCubit(this.dio) : super([]);

  Future<void> fetchPosts() async {
    try {
      final response = await dio.get('https://jsonplaceholder.typicode.com/posts');
      emit(response.data);
    } catch (e) {
      emit([]);
    }
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => PostCubit(Dio()),
        child: PostPage(),
      ),
    );
  }
}

class PostPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cubit = context.watch<PostCubit>();
    return Scaffold(
      appBar: AppBar(title: Text('Dio + BLoC Demo')),
      body: Column(
        children: [
          ElevatedButton(
            onPressed: () => context.read<PostCubit>().fetchPosts(),
            child: Text('Fetch Posts'),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: cubit.state.length,
              itemBuilder: (_, i) => ListTile(
                title: Text(cubit.state[i]['title']),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

What is the purpose of a CancelToken in Dio?

A
To cancel ongoing HTTP requests
B
To authenticate requests
C
To log requests
D
To format request bodies
Q2
of 3

Which pattern separates network logic from BLoC?

A
Singleton pattern
B
Repository pattern
C
Factory pattern
D
Observer pattern
Q3
of 3

What should you do inside a BLoC's `close` method regarding Dio?

A
Call `dio.close()`
B
Cancel any active cancel tokens
C
Log out the user
D
Nothing, BLoC handles it automatically

Frequently Asked Questions

Should I use Dio or the http package with BLoC?

Dio is recommended for most production apps because of its interceptor support, cancel tokens, form data, and global configuration. The http package is simpler but lacks these advanced features.

How do I handle file uploads with Dio and BLoC?

Use FormData to build the request. In the repository, call dio.post('/upload', data: formData). The BLoC can emit progress states using onSendProgress callback.

What's the best way to handle token refresh with Dio interceptors?

Implement a custom interceptor that catches 401 errors, refreshes the token, and retries the original request. Be careful to avoid infinite loops.

Can I use multiple CancelTokens for different requests?

Yes, create a new CancelToken per request or per BLoC. Cancel them when needed to avoid memory leaks.

How do I test Dio interceptors?

You can unit test interceptors by passing a mock RequestInterceptorHandler and ErrorInterceptorHandler. For integration, use Dio with a mocked HTTP client (e.g., http_mock_adapter).

Previous

bloc navigation guards

Next

bloc caching strategy

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.