Mobile

Bangun REST API Client di Flutter dengan Provider dan Dio

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 _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 cancelToken kalau user bisa navigate away sebelum response balik. Memory leak dari Dio request yang nggak di-cancel itu nyata.
  • Log request/response di development pakai LogInterceptor bawaan Dio, tapi matikan di production.
  • Cache response kalau data nggak sering berubah. Dio punya DioCacheManager atau kamu bisa pakai shared_preferences buat 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!


You may also like


0 Comments


Leave a Reply

Comments with links or spam keywords will be rejected.
Scroll to Top