Pertama kali bikin aplikasi Flutter yang konek ke API, saya langsung bikin semua logic di satu widget. Hasilnya? Kode berantakan, loading state nggak jelas, dan error handling asal-asalan. Kalau kamu pernah ngalamin hal yang sama, tenang kamu nggak sendirian.
Di artikel ini, saya mau share cara bikin REST API client di Flutter yang bener pakai Dio buat HTTP request dan Provider buat state management. Ini pattern yang saya pakai di project production dan hasilnya jauh lebih maintainable dibanding langsung pakai http package tanpa arsitektur yang jelas.
Kenapa Dio Bukan http Package Biasa
Flutter punya http package bawaan yang bisa dipakai buat request ke API. Tapi begitu project mulai kompleks, kamu bakal butuh fitur yang nggak ada di http package:
- Interceptors: Bisa nambahin token auth otomatis ke setiap request tanpa ngulang kode
- Request cancellation: User pindah halaman sebelum response balik? Bisa cancel requestnya
- Retry mechanism: Request gagal karena timeout? Dio bisa retry otomatis
- Multipart upload: Upload file jauh lebih gampang
- Base URL configuration: Setting base URL sekali, semua endpoint langsung pakai
Install Dio dan Provider di pubspec.yaml:
dependencies:
flutter:
sdk: flutter
dio: ^5.7.0
provider: ^6.1.2
flutter_dotenv: ^5.2.1
Kalau udah, jalankan flutter pub get dan kita mulai setup.
Setup Dio Client dengan Interceptor
Langkah pertama: bikin wrapper Dio yang udah dikonfigurasi. Ini penting supaya kamu nggak perlu nulis base URL, header, dan error handling berulang-ulang di setiap file.
import 'package:dio/dio.dart';
class ApiClient {
late final Dio _dio;
ApiClient({String? baseUrl}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl ?? 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
_dio.interceptors.addAll([
_AuthInterceptor(),
_LoggingInterceptor(),
]);
}
Dio get dio => _dio;
}
Perhatikan di situ ada connectTimeout dan receiveTimeout. Ini sering dilupain, tapi kalau API server down atau lambat, aplikasi kamu bakal nge-hang tanpa batas waktu tanpa timeout yang jelas.
Auth Interceptor untuk Token Otomatis
Ini fitur paling powerful dari Dio. Interceptor bisa nangkep setiap request dan response, jadi kamu bisa nambahin token auth tanpa harus nulis ulang di setiap endpoint.
class _AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = AuthService.instance.token;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final refreshed = await _refreshToken();
if (refreshed) {
final response = await _retry(err.requestOptions);
handler.resolve(response);
return;
}
}
handler.next(err);
}
Future<bool> _refreshToken() async {
try {
final response = await Dio().post(
'https://api.example.com/v1/auth/refresh',
data: {'refresh_token': AuthService.instance.refreshToken},
);
AuthService.instance.token = response.data['access_token'];
return true;
} catch (_) {
AuthService.instance.logout();
return false;
}
}
Future<Response> _retry(RequestOptions options) {
final dio = Dio();
return dio.fetch(options);
}
}
Kode di atas handle 3 hal sekaligus: inject token ke request, detect 401 error, dan auto-refresh token kalau expired. Tanpa interceptor, kamu harus nulis logika ini di setiap API call.
Bikin Model Class untuk Response
Jangan pernah pakai raw Map atau dynamic buat handle API response. Selalu bikin model class yang strongly typed. Ini nge-save banyak waktu debugging.
class Product {
final int id;
final String name;
final double price;
final String category;
final String? imageUrl;
final DateTime createdAt;
Product({
required this.id,
required this.name,
required this.price,
required this.category,
this.imageUrl,
required this.createdAt,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
category: json['category'] as String,
imageUrl: json['image_url'] as String?,
createdAt: DateTime.parse(json['created_at']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
'category': category,
'image_url': imageUrl,
'created_at': createdAt.toIso8601String(),
};
}
}
Tips: pakai factory constructor dari JSON supaya object creation konsisten. Kalau API response berubah field-nya, kamu cuma perlu update di satu tempat.
Repository Pattern untuk API Calls
Ini layer yang sering dilewatin. Langsung panggil Dio dari Provider? Bisa, tapi bakal susah di-test dan di-refactor. Lebih baik bikin repository class yang handle semua API logic.
class ProductRepository {
final Dio _dio;
ProductRepository(this._dio);
Future<List<Product>> getProducts({int page = 1, int limit = 20}) async {
try {
final response = await _dio.get(
'/products',
queryParameters: {'page': page, 'limit': limit},
);
final data = response.data['data'] as List;
return data.map((json) => Product.fromJson(json)).toList();
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<Product> getProductById(int id) async {
try {
final response = await _dio.get('/products/$id');
return Product.fromJson(response.data['data']);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<Product> createProduct(Map<String, dynamic> data) async {
try {
final response = await _dio.post('/products', data: data);
return Product.fromJson(response.data['data']);
} on DioException catch (e) {
throw _handleError(e);
}
}
ApiException _handleError(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ApiException('Koneksi timeout, coba lagi nanti', 408);
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode ?? 0;
final message = e.response?.data?['message'] ?? 'Terjadi kesalahan';
return ApiException(message, statusCode);
case DioExceptionType.connectionError:
return ApiException('Tidak ada koneksi internet', 0);
default:
return ApiException('Error tidak diketahui', 0);
}
}
}
class ApiException implements Exception {
final String message;
final int statusCode;
ApiException(this.message, this.statusCode);
@override
String toString() => 'ApiException($statusCode): $message';
}
Kenapa pattern ini bagus? Karena ProductRepository bisa di-mock waktu unit testing. Kamu nggak perlu bikin HTTP request beneran buat test logic di Provider.
Provider untuk State Management
Sekarang kita hubungin repository ke UI pakai Provider. Pola yang saya pakai: satu Provider per fitur, dengan state yang jelas (loading, loaded, error).
import 'package:flutter/material.dart';
enum ViewState { idle, loading, loaded, error }
class ProductProvider extends ChangeNotifier {
final ProductRepository _repository;
ProductProvider(this._repository);
ViewState _state = ViewState.idle;
List<Product> _products = [];
String? _errorMessage;
int _currentPage = 1;
bool _hasMore = true;
ViewState get state => _state;
List<Product> get products => _products;
String? get errorMessage => _errorMessage;
bool get hasMore => _hasMore;
bool get isLoading => _state == ViewState.loading;
Future<void> fetchProducts({bool refresh = false}) async {
if (refresh) {
_currentPage = 1;
_hasMore = true;
_products = [];
}
if (!_hasMore || _state == ViewState.loading) return;
_state = ViewState.loading;
_errorMessage = null;
notifyListeners();
try {
final newProducts = await _repository.getProducts(page: _currentPage);
if (newProducts.isEmpty) {
_hasMore = false;
} else {
_products.addAll(newProducts);
_currentPage++;
}
_state = ViewState.loaded;
} on ApiException catch (e) {
_state = ViewState.error;
_errorMessage = e.message;
} catch (e) {
_state = ViewState.error;
_errorMessage = 'Terjadi kesalahan tidak terduga';
}
notifyListeners();
}
}
Perhatikan ada pagination logic di situ. _hasMore flag nge-stop request kalau udah nggak ada data lagi. Tanpa ini, user bisa scroll terus dan app bakal request API yang sama berulang-ulang.
Connecting ke Main App
Setup Provider di root widget pakai MultiProvider:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
final apiClient = ApiClient(baseUrl: 'https://api.example.com/v1');
final productRepo = ProductRepository(apiClient.dio);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => ProductProvider(productRepo),
),
// Tambah provider lain di sini
],
child: const MyApp(),
),
);
}
Pattern ini bikin dependency injection sederhana. Repository di-create sekali di main(), terus di-inject ke Provider. Kalau nanti mau ganti ke state management lain (Riverpod, BLoC), repository-nya tetap bisa dipakai ulang.
Consumer Widget di UI
Sekarang di widget, pakai Consumer atau context.watch buat rebuild otomatis:
class ProductListScreen extends StatefulWidget {
const ProductListScreen({super.key});
@override
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ProductProvider>().fetchProducts();
});
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
context.read<ProductProvider>().fetchProducts();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Produk')),
body: Consumer<ProductProvider>(
builder: (context, provider, child) {
if (provider.state == ViewState.loading && provider.products.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (provider.state == ViewState.error && provider.products.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(provider.errorMessage ?? 'Error'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => provider.fetchProducts(refresh: true),
child: const Text('Coba Lagi'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => provider.fetchProducts(refresh: true),
child: ListView.builder(
controller: _scrollController,
itemCount: provider.products.length + (provider.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == provider.products.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
final product = provider.products[index];
return ListTile(
title: Text(product.name),
subtitle: Text('Rp ${product.price.toStringAsFixed(0)}'),
trailing: Text(product.category),
);
},
),
);
},
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Ada tiga state di UI yang harus di-handle: loading awal (pakai CircularProgressIndicator), error state (pesan + tombol retry), dan loaded state (ListView). Kebanyakan tutorial cuma handle loading dan loaded, tapi error handling itu yang paling penting buat user experience.
Error Handling yang Robust
Saya pernah punya app yang crash karena API return format beda pas server maintenance. Sejak itu, saya selalu bikin error handling yang defensive:
// Extension method supaya error handling konsisten
extension ResponseExtension on Response {
Map<String, dynamic> get safeData {
if (data is Map<String, dynamic>) {
return data as Map<String, dynamic>;
}
return {'raw': data};
}
List get safeList {
if (data is Map && data['data'] is List) {
return data['data'] as List;
}
if (data is List) {
return data;
}
return [];
}
}
Tips lain: selalu handle kasus di mana data null atau typenya nggak sesuai ekspektasi. API bisa berubah tanpa notice, dan app kamu harus tetap jalan.
Unit Testing Repository
Bonus: contoh test sederhana pakai mock Dio. Ini kenapa repository pattern penting kamu bisa test tanpa HTTP call beneran.
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:dio/dio.dart';
class MockDio extends Mock implements Dio {}
void main() {
late ProductRepository repository;
late MockDio mockDio;
setUp(() {
mockDio = MockDio();
repository = ProductRepository(mockDio);
});
test('getProducts returns list of products', () async {
when(mockDio.get('/products', queryParameters: anyNamed('queryParameters')))
.thenAnswer((_) async => Response(
data: {
'data': [
{'id': 1, 'name': 'Test', 'price': 10000, 'category': 'A', 'created_at': '2026-01-01T00:00:00Z'},
]
},
statusCode: 200,
requestOptions: RequestOptions(path: '/products'),
));
final products = await repository.getProducts();
expect(products.length, 1);
expect(products.first.name, 'Test');
});
}
Tips Tambahan
Beberapa hal yang sering kelewat waktu bikin REST client di Flutter:
- Pakai
cancelTokenkalau user bisa navigate away sebelum response balik. Memory leak dari Dio request yang nggak di-cancel itu nyata. - Log request/response di development pakai
LogInterceptorbawaan Dio, tapi matikan di production. - Cache response kalau data nggak sering berubah. Dio punya
DioCacheManageratau kamu bisa pakaishared_preferencesbuat simple cache. - Pakai
compute()buat parse JSON yang besar (1000+ items) supaya UI nggak jank. - Handle offline mode check koneksi internet dulu sebelum request, dan cache data terakhir supaya app tetap bisa ditampilkan.
Pattern Dio + Provider + Repository ini udah saya pakai di beberapa project dan hasilnya jauh lebih maintainable dibanding langsung http.get() di dalam widget. Kodenya terstruktur, gampang di-test, dan kalau nanti mau migrasi ke state management lain, repository layer-nya tetap reusable.
Kalau ada pertanyaan atau mau diskusi tentang pattern lain kayak BLoC atau Riverpod, tulis di komentar ya!