flutter
/

GetX API Integration: Fetch Data with Reactive State

Last Sync: Today

On this page

20
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

GetX API Integration: Fetch Data with Reactive State

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:

DARTRead-only
1
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:

DARTRead-only
1
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.

DARTRead-only
1
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) {
        // Add auth token to every request
        options.headers['Authorization'] = 'Bearer YOUR_TOKEN';
        return handler.next(options);
      },
      onResponse: (response, handler) => handler.next(response),
      onError: (error, handler) {
        // Global error handling
        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}');
    }
  }

  // Cancel token example
  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.

DARTRead-only
1
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.

DARTRead-only
1
class PostsBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut<ApiService>(() => ApiService());
    Get.lazyPut<PostsController>(() => PostsController(Get.find()));
  }
}

// In route
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.

DARTRead-only
1
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.

DARTRead-only
1
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()));
    }
  }
}

// In UI
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.

DARTRead-only
1
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) {
      // handle error
    } finally {
      isLoadingMore.value = false;
    }
  }
}

Debounce for Search Inputs

When implementing search, debounce prevents excessive API calls. Use GetX's debounce worker:

DARTRead-only
1
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;
    }
  }
}

// In UI
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.

DARTRead-only
1
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.

DARTRead-only
1
// Global interceptor example
_dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) {
    // Add auth token
    options.headers['Authorization'] = 'Bearer $token';
    return handler.next(options);
  },
  onError: (error, handler) {
    if (error.response?.statusCode == 401) {
      // Handle unauthorized
      Get.offAllNamed('/login');
    }
    return handler.next(error);
  },
));

// CancelToken usage
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:

DARTRead-only
1
class CachedApiService extends GetxService {
  final GetStorage box = GetStorage();
  final String cacheKey = 'posts_cache';
  final Duration cacheDuration = Duration(minutes: 5);

  Future<List<dynamic>> fetchPosts() async {
    // Check cache
    final cached = box.read(cacheKey);
    if (cached != null) {
      final timestamp = box.read('${cacheKey}_time');
      if (DateTime.now().difference(DateTime.parse(timestamp)) < cacheDuration) {
        return cached;
      }
    }
    // Fetch fresh data
    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

FeatureGetXProviderRiverpod
BoilerplateMinimalModerateModerate
ReactivityBuilt-in (.obs)ChangeNotifierref.watch
Dependency InjectionBuilt-inExternalBuilt-in
RoutingBuilt-inExternalExternal
PerformanceHigh (reactive)GoodGood
Learning CurveEasyMediumMedium

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.

Try it yourself

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

void main() => runApp(MyApp());

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');
    }
  }
}

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;
    }
  }
}

class PostsBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut<ApiService>(() => ApiService());
    Get.lazyPut<PostsController>(() => PostsController(Get.find()));
  }
}

class PostsPage extends GetView<PostsController> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('API Integration')),
      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']),
            );
          },
        );
      }),
    );
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      initialRoute: '/',
      getPages: [
        GetPage(name: '/', page: () => PostsPage(), binding: PostsBinding()),
      ],
    );
  }
}

Test Your Knowledge

Q1
of 3

What is the recommended way to hold API data in a GetX controller?

A
A normal variable
B
A reactive variable (e.g., `.obs`)
C
A Future
D
A Stream
Q2
of 3

Which GetX feature can help reduce boilerplate for loading/error/success states?

A
StateMixin
B
Obx
C
GetBuilder
D
Workers
Q3
of 3

How do you replace all items in an RxList with a new list?

A
list.value = newList
B
list.assignAll(newList)
C
list.clear() then list.addAll
D
Both B and C

Frequently Asked Questions

Should I use `http` or `dio`?

dio offers more features like interceptors, cancel tokens, and form data. http is simpler for basic needs.

How do I add headers (e.g., authentication) to API calls?

In your service, pass headers to the HTTP client. For dio, you can use interceptors to add them globally.

How do I cache API responses?

You can use GetStorage or a more advanced caching package like hive. Store data after fetching and load from cache when offline.

How do I handle multiple simultaneous requests?

Use Future.wait or Stream.fromFutures. With GetX, you can use Obx with multiple reactive variables.

How to test API integration with GetX?

Mock the service using a fake implementation and inject it via Get.put in tests.

How to handle token refresh automatically?

Use a Dio interceptor to detect 401 errors and attempt token refresh before retrying the request.

Previous

getx internationalization

Next

getx state mixin

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.