With Dio
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
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>.
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();
}
}
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
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
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:
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:
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:
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;
}
}
}
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:
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
- Error Handling: Global
errorMapperand per-querytransformError - QoraOptions API:
retryCount,retryDelay,retryCondition - Mutations: Write operations with Dio and optimistic updates
- Network Aware: Dio with offline-first patterns and
NetworkMode