Third Party

With Dio

[object Object]

Dio is the most popular HTTP client in the Dart/Flutter ecosystem. This guide covers idiomatic Qora+Dio usage: error mapping, interceptors for auth tokens and token refresh, and copy-paste ready fetcher functions.


Setup

pubspec.yaml
dependencies:
  qora: ^ 1.3.1
  dio: ^5.0.0

Core Pattern: Dio as Fetcher

The fetcher function passed to QoraClient.fetchQuery or QoraBuilder should use Dio for the HTTP call and let Dio exceptions propagate up. Qora catches them, applies retry logic, and surfaces the error via QoraState<Failure>.

lib/data/user_api.dart
import 'package:dio/dio.dart';
import 'package:qora/qora.dart';

final api = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

class UserApi {
  Future<User> getUser(String id) async {
    final res = await api.get('/users/$id');
    return User.fromJson(res.data);
  }

  Future<List<User>> listUsers() async {
    final res = await api.get('/users');
    return (res.data as List).map((j) => User.fromJson(j)).toList();
  }
}
lib/features/user_screen.dart
QoraBuilder<User>(
  queryKey: ['user', userId],
  fetcher: () => userApi.getUser(userId),
  builder: (context, state, _) => switch (state) {
    Success(:final data) => UserCard(data),
    Loading() => const CircularProgressIndicator(),
    Failure(:final error) => ErrorView(error),
    _ => const SizedBox.shrink(),
  },
)

Error Mapping: DioException to App Errors

By default, Qora stores the raw DioException in the Failure state. Register a global errorMapper on QoraClientConfig to convert Dio errors into typed app errors, or use per-query QoraOptions.transformError.

Global Error Mapper

lib/core/qora_setup.dart
import 'package:dio/dio.dart';
import 'package:qora/qora.dart';

/// Typed app error. `errorMapper` must return a `QoraException`, so extend it.
class AppError extends QoraException {
  final int? statusCode;
  const AppError(super.message, {this.statusCode, super.originalError, super.stackTrace});
}

final client = QoraClient(
  config: QoraClientConfig(
    errorMapper: (Object error, StackTrace? stackTrace) {
      if (error is DioException) {
        return switch (error.type) {
          DioExceptionType.connectionTimeout => AppError('Connection timed out', originalError: error),
          DioExceptionType.receiveTimeout => AppError('Server is not responding', originalError: error),
          DioExceptionType.badResponse => _mapHttpStatus(error),
          DioExceptionType.connectionError => AppError('No internet connection', originalError: error),
          _ => AppError(error.message ?? 'Request failed', originalError: error),
        };
      }
      if (error is QoraException) return error;
      return QoraException(error.toString(), originalError: error, stackTrace: stackTrace);
    },
  ),
);

AppError _mapHttpStatus(DioException error) {
  final status = error.response?.statusCode ?? 0;
  final message = error.response?.data?['message'] as String? ?? 'Request failed';
  return AppError(message, statusCode: status, originalError: error);
}

Per-Query Error Transform

lib/features/profile_screen.dart
QoraBuilder<Profile>(
  queryKey: ['profile'],
  fetcher: () => api.get('/profile').then((r) => Profile.fromJson(r.data)),
  options: QoraOptions(
    transformError: (error) {
      if (error is DioException && error.type == DioExceptionType.badResponse) {
        return ApiError.fromResponse(error.response!);
      }
      return error;
    },
  ),
  builder: (context, state, _) { ... },
)

Auth Interceptor

Use a Dio interceptor to attach an access token to every request. The token source (e.g., secure storage) is consulted on each request:

lib/core/dio_setup.dart
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

final storage = const FlutterSecureStorage();

final api = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
  ..interceptors.add(InterceptorsWrapper(
    onRequest: (options, handler) async {
      final token = await storage.read(key: 'access_token');
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      handler.next(options);
    },
  ));

Token Refresh Interceptor

When the server returns a 401, attempt a silent token refresh, then retry the original request exactly once:

lib/core/dio_setup.dart
final api = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
  ..interceptors.addAll([
    _AuthInterceptor(),
    _TokenRefreshInterceptor(api, storage),
  ]);

class _AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await storage.read(key: 'access_token');
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}

class _TokenRefreshInterceptor extends QueuedInterceptor {
  _TokenRefreshInterceptor(this._dio, this._storage);

  final Dio _dio;
  final FlutterSecureStorage _storage;
  bool _isRefreshing = false;

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401 || _isRefreshing) {
      return handler.next(err);
    }

    _isRefreshing = true;
    try {
      final refreshToken = await _storage.read(key: 'refresh_token');
      if (refreshToken == null) {
        _isRefreshing = false;
        return handler.next(err);
      }

      final res = await _dio.post('/auth/refresh',
          data: {'refresh_token': refreshToken});
      final newAccessToken = res.data['access_token'] as String;
      await _storage.write(key: 'access_token', value: newAccessToken);

      _isRefreshing = false;

      // Retry the original request with the new token.
      final opts = err.requestOptions;
      opts.headers['Authorization'] = 'Bearer $newAccessToken';
      final retryResponse = await _dio.fetch(opts);
      return handler.resolve(retryResponse);
    } catch (e) {
      _isRefreshing = false;
      return handler.next(err);
    }
  }
}

Qora's retry mechanism works above this layer. If the token refresh fails or the retried request still fails, the DioException propagates to Qora, which applies its own retryCount / retryDelay logic.


Complete Fetcher with Error Typing

For a fully typed fetcher that returns domain models:

lib/data/todo_api.dart
class TodoApi {
  TodoApi(this._api);
  final Dio _api;

  Future<Todo> getTodo(String id) async {
    try {
      final res = await _api.get('/todos/$id');
      return Todo.fromJson(res.data);
    } on DioException {
      rethrow; // let Qora errorMapper handle it
    }
  }

  Future<List<Todo>> listTodos({String? filter}) async {
    try {
      final res = await _api.get('/todos', queryParameters: {
        if (filter != null) 'filter': filter,
      });
      return (res.data as List).map((j) => Todo.fromJson(j)).toList();
    } on DioException {
      rethrow;
    }
  }
}
lib/features/todo_screen.dart
QoraBuilder<List<Todo>>(
  queryKey: ['todos', filter],
  fetcher: () => todoApi.listTodos(filter: filter),
  options: const QoraOptions(
    staleTime: Duration(minutes: 1),
    retryCount: 2,
  ),
  builder: (context, state, _) {
    if (state.errorOrNull case final error?) {
      return TodoErrorView(error, onRetry: () => context.qora.invalidate(['todos', filter]));
    }
    return ListView.builder(
      itemCount: (state.dataOrNull ?? []).length,
      itemBuilder: (_, i) => TodoTile(state.dataOrNull![i]),
    );
  },
)

Testing Dio Fetchers

Use Dio with an adapter override (HttpClientAdapter or DioMixin) so tests run without a real network:

test/data/todo_api_test.dart
import 'package:dio/dio.dart';
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  late Dio dio;
  late QoraClient client;

  setUp(() {
    dio = Dio(BaseOptions(baseUrl: 'https://test.example.com'));
    // Replace with a mock adapter for offline testing.
    dio.httpClientAdapter = _MockAdapter();

    client = QoraClient();
  });

  test('fetcher success flows into Qora Success state', () async {
    final result = await client.fetchQuery<Todo>(
      key: ['todo', 1],
      fetcher: () async {
        final res = await dio.get('/todos/1');
        return Todo.fromJson(res.data);
      },
    );

    expect(result.title, 'Test Todo');
    expect(client.getQueryState<Todo>(['todo', 1]), isA<Success<Todo>>());
  });
}

Next Steps

Copyright © 2026