Introduction
Integrating APIs is a core part of most Flutter apps. GetX simplifies this by providing reactive state, dependency injection, and a clean separation of concerns. This guide shows how to fetch data from an API, handle loading and error states, and display the results reactively using GetX. We'll cover both the built-in http package and the more powerful dio package, along with advanced patterns like pagination, debounce, and interceptors.
How GetX API Flow Works
The recommended architecture for API integration with GetX follows a unidirectional data flow:
UI → Controller → Service → API → Controller → UI
- UI (View) uses
Obx or GetView to observe reactive variables.
- Controller holds reactive state (data, loading, error) and business logic.
- Service handles HTTP requests, usually injected via GetX dependency injection.
- API returns data which is then updated in the controller, automatically refreshing the UI.
Setting Up the Service with HTTP
Create a service class that extends GetxService to manage API calls. This service will be injected into controllers. Here’s a basic example using the http package:
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class ApiService extends GetxService {
final String baseUrl = 'https://jsonplaceholder.typicode.com';
Future<List<dynamic>> fetchPosts() async {
final response = await http.get(Uri.parse('$baseUrl/posts'));
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('Failed to load posts');
}
}
}
Adding Authentication Headers
For authenticated endpoints, you need to include headers. You can pass them directly or store a token in a service:
class AuthApiService extends GetxService {
final String baseUrl = 'https://yourapi.com';
String? _token;
void setToken(String token) => _token = token;
Future<Map<String, String>> _getHeaders() async {
return {
'Authorization': 'Bearer $_token',
'Content-Type': 'application/json',
};
}
Future<Map<String, dynamic>> getProfile() async {
final response = await http.get(
Uri.parse('$baseUrl/profile'),
headers: await _getHeaders(),
);
if (response.statusCode == 200) {
return json.decode(response.body);
} else {
throw Exception('Failed to load profile');
}
}
}
Using Dio (Advanced API Handling)
Dio is a powerful HTTP client that supports interceptors, cancel tokens, form data, and more. First add dio: ^5.0.0 to your pubspec.yaml.
import 'package:dio/dio.dart';
import 'package:get/get.dart';
class DioService extends GetxService {
late Dio _dio;
@override
void onInit() {
super.onInit();
_dio = Dio(BaseOptions(
baseUrl: 'https://jsonplaceholder.typicode.com',
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
));
_setupInterceptors();
}
void _setupInterceptors() {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer YOUR_TOKEN';
return handler.next(options);
},
onResponse: (response, handler) => handler.next(response),
onError: (error, handler) {
return handler.next(error);
},
));
}
Future<List<dynamic>> fetchPosts() async {
try {
final response = await _dio.get('/posts');
return response.data;
} on DioException catch (e) {
throw Exception('Dio error: ${e.message}');
}
}
CancelToken cancelToken = CancelToken();
Future<void> cancelRequest() => cancelToken.cancel('Request cancelled');
}
Controller with Reactive State
Create a controller that holds reactive state. It will call the service and update the state accordingly.
class PostsController extends GetxController {
final ApiService apiService;
PostsController(this.apiService);
var posts = <dynamic>[].obs;
var isLoading = false.obs;
var error = ''.obs;
@override
void onInit() {
super.onInit();
fetchPosts();
}
Future<void> fetchPosts() async {
isLoading.value = true;
error.value = '';
try {
final result = await apiService.fetchPosts();
posts.assignAll(result);
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
}
Dependency Injection with Bindings
Use a binding to register the service and controller. This ensures they are created when the route is opened.
class PostsBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ApiService>(() => ApiService());
Get.lazyPut<PostsController>(() => PostsController(Get.find()));
}
}
GetPage(
name: '/posts',
page: () => PostsPage(),
binding: PostsBinding(),
);
UI with Obx
The UI listens to the controller's reactive variables using Obx. It shows a loading indicator, error message, or the list of data.
class PostsPage extends GetView<PostsController> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Posts')),
body: Obx(() {
if (controller.isLoading.value) {
return Center(child: CircularProgressIndicator());
}
if (controller.error.value.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${controller.error.value}'),
ElevatedButton(
onPressed: controller.fetchPosts,
child: Text('Retry'),
),
],
),
);
}
return ListView.builder(
itemCount: controller.posts.length,
itemBuilder: (_, index) {
final post = controller.posts[index];
return ListTile(
title: Text(post['title']),
subtitle: Text(post['body']),
);
},
);
}),
);
}
}
Using StateMixin for Cleaner Code
GetX provides StateMixin that reduces boilerplate by handling loading, error, and success states. Extend StateMixin<T> and use change to update the state.
class PostsController extends GetxController with StateMixin<List<dynamic>> {
final ApiService apiService;
PostsController(this.apiService);
@override
void onInit() {
super.onInit();
fetchPosts();
}
Future<void> fetchPosts() async {
change(null, status: RxStatus.loading());
try {
final result = await apiService.fetchPosts();
change(result, status: RxStatus.success());
} catch (e) {
change(null, status: RxStatus.error(e.toString()));
}
}
}
class PostsPage extends GetView<PostsController> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Posts')),
body: controller.obx(
(data) => ListView.builder(
itemCount: data!.length,
itemBuilder: (_, index) => ListTile(
title: Text(data[index]['title']),
subtitle: Text(data[index]['body']),
),
),
onLoading: Center(child: CircularProgressIndicator()),
onError: (error) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: controller.fetchPosts,
child: Text('Retry'),
),
],
),
),
),
);
}
}
Handling Pagination
For paginated data, you can use a combination of reactive lists and a loadMore method. Use debounce to avoid excessive calls while scrolling.
class PaginatedController extends GetxController {
final ApiService apiService;
PaginatedController(this.apiService);
var items = <dynamic>[].obs;
var isLoadingMore = false.obs;
var hasMore = true.obs;
int page = 1;
Future<void> fetchItems({bool refresh = false}) async {
if (refresh) {
page = 1;
items.clear();
hasMore.value = true;
}
if (isLoadingMore.value || !hasMore.value) return;
isLoadingMore.value = true;
try {
final newItems = await apiService.getItems(page);
if (newItems.isEmpty) {
hasMore.value = false;
} else {
items.addAll(newItems);
page++;
}
} catch (e) {
} finally {
isLoadingMore.value = false;
}
}
}
Debounce for Search Inputs
When implementing search, debounce prevents excessive API calls. Use GetX's debounce worker:
class SearchController extends GetxController {
var searchQuery = ''.obs;
var results = <dynamic>[].obs;
var isLoading = false.obs;
@override
void onInit() {
super.onInit();
debounce(searchQuery, (_) => performSearch(), time: Duration(milliseconds: 500));
}
Future<void> performSearch() async {
if (searchQuery.value.isEmpty) {
results.clear();
return;
}
isLoading.value = true;
try {
final data = await apiService.search(searchQuery.value);
results.assignAll(data);
} finally {
isLoading.value = false;
}
}
}
TextField(
onChanged: (value) => controller.searchQuery.value = value,
)
Obx(() => controller.isLoading.value ? CircularProgressIndicator() : ListView(...))
Error Handling with Workers
You can use workers to react to errors globally, for example to show a snackbar when an error occurs.
class PostsController extends GetxController {
@override
void onInit() {
super.onInit();
ever(error, (err) {
if (err.isNotEmpty) {
Get.snackbar('Error', err, snackPosition: SnackPosition.BOTTOM);
}
});
fetchPosts();
}
}
Advanced: Dio Interceptors and CancelToken
Dio interceptors let you handle authentication, logging, and errors globally. CancelToken allows cancelling pending requests.
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $token';
return handler.next(options);
},
onError: (error, handler) {
if (error.response?.statusCode == 401) {
Get.offAllNamed('/login');
}
return handler.next(error);
},
));
CancelToken cancelToken = CancelToken();
Future<void> fetchData() async {
try {
final response = await _dio.get('/data', cancelToken: cancelToken);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
print('Request cancelled');
}
}
}
void dispose() => cancelToken.cancel();
Performance and Caching Strategies
To optimize API calls and provide offline support, implement caching. Use GetStorage or Hive to store responses locally. Here’s a simple caching approach:
class CachedApiService extends GetxService {
final GetStorage box = GetStorage();
final String cacheKey = 'posts_cache';
final Duration cacheDuration = Duration(minutes: 5);
Future<List<dynamic>> fetchPosts() async {
final cached = box.read(cacheKey);
if (cached != null) {
final timestamp = box.read('${cacheKey}_time');
if (DateTime.now().difference(DateTime.parse(timestamp)) < cacheDuration) {
return cached;
}
}
final fresh = await _fetchFromApi();
box.write(cacheKey, fresh);
box.write('${cacheKey}_time', DateTime.now().toIso8601String());
return fresh;
}
}
Real-World Examples
Authentication Flow: After login, store the token in GetStorage and inject it into the Dio interceptor.
Filter API: Use reactive variables for filter criteria and combine with debounce to trigger new requests.
Upload File: Use Dio's FormData to send multipart requests with progress tracking.
GetX vs Provider vs Riverpod for API Handling
| Feature | GetX | Provider | Riverpod |
|---|
| Boilerplate | Minimal | Moderate | Moderate |
| Reactivity | Built-in (.obs) | ChangeNotifier | ref.watch |
| Dependency Injection | Built-in | External | Built-in |
| Routing | Built-in | External | External |
| Performance | High (reactive) | Good | Good |
| Learning Curve | Easy | Medium | Medium |
GetX excels in reducing boilerplate and providing an all-in-one solution, making it ideal for rapid development and smaller teams.
Best Practices
- Use
GetxService for API clients – They are permanent and can be reused across controllers. - Keep controllers focused – One controller per screen or data entity.
- Use
StateMixin – It reduces boilerplate for loading/error/success states. - Handle errors gracefully – Show user-friendly messages and provide a retry option.
- Use
debounce for search inputs – Avoid making API calls on every keystroke. - Cancel previous requests when appropriate – Use
CancelToken with dio to avoid race conditions. - Implement caching – Reduce network calls and improve offline experience.
Common Mistakes
- ❌ Calling API in the view – Mixing logic with UI.
✅ Call APIs in controllers or services.
- ❌ Not handling loading/error states – User experience suffers.
✅ Always show loading indicators and handle errors.
- ❌ Forgetting to dispose controllers – Services are permanent, but controllers should be disposed.
✅ Use bindings to manage lifecycle.
- ❌ Not using
assignAll for lists – Using list.value = newList doesn't trigger UI updates correctly.
✅ Use assignAll to replace list contents. - ❌ Ignoring timeouts – Unresponsive UI when network is slow.
✅ Set timeouts in Dio or http.
FAQ
- Q: Should I use
http or dio?
A: dio offers more features like interceptors, cancel tokens, and form data. http is simpler for basic needs. - Q: How do I add headers (e.g., authentication) to API calls?
A: In your service, pass headers to the HTTP client. For dio, you can use interceptors to add them globally. - Q: How do I cache API responses?
A: You can use GetStorage or a more advanced caching package like hive. Store data after fetching and load from cache when offline. - Q: How do I handle multiple simultaneous requests?
A: Use Future.wait or Stream.fromFutures. With GetX, you can use Obx with multiple reactive variables. - Q: How to test API integration with GetX?
A: Mock the service using a fake implementation and inject it via Get.put in tests. - Q: How to handle token refresh automatically?
A: Use a Dio interceptor to detect 401 errors and attempt token refresh before retrying the request.
Conclusion
GetX provides a clean, reactive approach to API integration. By combining services, controllers with reactive state, and proper error handling, you can build robust data-driven Flutter apps with minimal boilerplate. The pattern is scalable and testable, making it ideal for both small projects and large applications. For advanced use cases, Dio's interceptors and cancel tokens give you fine-grained control over networking.