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.
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:
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.
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.
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 _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.
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.
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.
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.
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.
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.
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.
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');
});
}
Beberapa hal yang sering kelewat waktu bikin REST client di Flutter:
cancelToken kalau user bisa navigate away sebelum response balik. Memory leak dari Dio request yang nggak di-cancel itu nyata.LogInterceptor bawaan Dio, tapi matikan di production.DioCacheManager atau kamu bisa pakai shared_preferences buat simple cache.compute() buat parse JSON yang besar (1000+ items) supaya UI nggak jank.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!