About Advanced GetX Patterns
Advanced GetX Patterns is a skill guide for developers building reactive Dart applications with GetX. It covers state-driven side effects, dependency and lifecycle management, HTTP communication, background queues, and testing.
Key features
- 01Reactive workers and side effects
- 02Permanent services and lifecycle control
- 03GetConnect HTTP client patterns
- 04Composed dependency bindings
- 05Rx transformations and background queues
- 06Controller and widget testing
Use cases
- 01Debouncing search input changes
- 02Managing app-wide authentication services
- 03Organizing route-specific dependencies
- 04Testing reactive controller behavior
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ kaakati/ rails-enterprise-dev --skill advanced-getx-patternsAfter installing, type/advanced-getx-patternsin Claude Code to confirm it loaded.
Details
From the project READMEAdvanced GetX Patterns
Advanced GetX patterns for building sophisticated reactive applications with proper state management, dependency injection, and network communication.
Workers - Reactive Side Effects
Workers allow you to execute callbacks when observable values change.
ever - Execute on Every Change
class UserController extends GetxController {
final user = Rx<User?>(null);
final isAuthenticated = false.obs;
@override
void onInit() {
super.onInit();
// Execute callback every time user changes
ever(user, (User? userData) {
if (userData != null) {
print('User logged in: ${userData.name}');
isAuthenticated.value = true;
} else {
print('User logged out');
isAuthenticated.value = false;
}
});
}
}
once - Execute Only Once
class OnboardingController extends GetxController {
final hasCompletedOnboarding = false.obs;
@override
void onInit() {
super.onInit();
// Execute only the first time value becomes true
once(hasCompletedOnboarding, (_) {
Get.offAllNamed(AppRoutes.home);
// This won't run again even if value changes back to false and then true
});
}
}
debounce - Delay Execution
class SearchController extends GetxController {
final searchQuery = ''.obs;
final searchResults = <Product>[].obs;
final isSearching = false.obs;
@override
void onInit() {
super.onInit();
// Wait 800ms after user stops typing before searching
debounce(
searchQuery,
(_) => performSearch(),
time: const Duration(milliseconds: 800),
);
}
Future<void> performSearch() async {
if (searchQuery.value.isEmpty) {
searchResults.clear();
return;
}
isSearching.value = true;
final result = await repository.search(searchQuery.value);
result.fold(
(failure) => searchResults.clear(),
(products) => searchResults.value = products,
);
isSearching.value = false;
}
}
interval - Execute Periodically
class DashboardController extends GetxController {
final stats = Rx<DashboardStats?>(null);
@override
void onInit() {
super.onInit();
// Refresh stats every 30 seconds while value changes
interval(
stats,
(_) => refreshStats(),
time: const Duration(seconds: 30),
);
// Initial load
refreshStats();
}
Future<void> refreshStats() async {
final result = await repository.getStats();
result.fold(
(failure) => {},
(data) => stats.value = data,
);
}
}
Worker Best Practices
class MyController extends GetxController {
final count = 0.obs;
Worker? _countWorker;
@override
void onInit() {
super.onInit();
// Store worker reference for manual disposal
_countWorker = ever(count, (value) {
print('Count changed to: $value');
});
}
@override
void onClose() {
// Dispose worker manually if needed
_countWorker?.dispose();
super.onClose();
}
}
GetxService - Permanent Services
GetxService instances are never disposed, perfect for app-wide services.
Creating a Service
import 'package:get/get.dart';
class AuthService extends GetxService {
final _isAuthenticated = false.obs;
bool get isAuthenticated => _isAuthenticated.value;
final _currentUser = Rx<User?>(null);
User? get currentUser => _currentUser.value;
// Called when service is first created
@override
Future<void> onInit() async {
super.onInit();
await _loadSavedAuth();
}
Future<void> _loadSavedAuth() async {
final storage = Get.find<GetStorage>();
final token = storage.read<String>('auth_token');
if (token != null) {
await validateToken(token);
}
}
Future<void> login(String email, String password) async {
final result = await repository.login(email, password);
result.fold(
(failure) => throw Exception(failure.message),
(user) {
_currentUser.value = user;
_isAuthenticated.value = true;
Get.find<GetStorage>().write('auth_token', user.token);
},
);
}
void logout() {
_currentUser.value = null;
_isAuthenticated.value = false;
Get.find<GetStorage>().remove('auth_token');
}
@override
void onClose() {
// GetxService onClose is never called
// Service persists throughout app lifecycle
super.onClose();
}
}
Registering Services
// In main.dart or dependency injection setup
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await GetStorage.init();
// Register permanent services
Get.putAsync(() => AuthService().init(), permanent: true);
Get.put(ThemeService(), permanent: true);
Get.put(LocalizationService(), permanent: true);
runApp(MyApp());
}
// With async initialization
class AuthService extends GetxService {
Future<AuthService> init() async {
await _loadSavedAuth();
return this;
}
}
SmartManagement - Lifecycle Control
Control how GetX manages controller lifecycle.
SmartManagement Modes
void main() {
runApp(
GetMaterialApp(
smartManagement: SmartManagement.full, // Default
home: HomePage(),
),
);
}
SmartManagement.full (Default):
- Disposes controllers when routes are closed
- Most memory efficient
- Recommended for most apps
SmartManagement.onlyBuilder:
- Only disposes controllers created with
GetBuilder Get.find()instances persist- Use when you need manual control
SmartManagement.keepFactory:
- Keeps factory instances
- Controllers can be recreated with same dependencies
- Use for complex dependency graphs
Manual Controller Disposal
class ManualController extends GetxController {
// This controller won't auto-dispose
}
// Register without auto-dispose
Get.put(ManualController(), permanent: true);
// Manually dispose when needed
Get.delete<ManualController>();
// Or with tag
Get.put(ManualController(), tag: 'unique-tag');
Get.delete<ManualController>(tag: 'unique-tag');
GetConnect - HTTP Client
GetConnect provides a powerful HTTP client with interceptors and base URL configuration.
Basic Setup
class ApiProvider extends GetConnect {
@override
void onInit() {
// Base URL
httpClient.baseUrl = 'https://api.example.com';
// Default timeout
httpClient.timeout = const Duration(seconds: 30);
// Request interceptor
httpClient.addRequestModifier<dynamic>((request) {
// Add auth token to all requests
final token = Get.find<AuthService>().token;
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
}
request.headers['Content-Type'] = 'application/json';
return request;
});
// Response interceptor
httpClient.addResponseModifier((request, response) {
// Log responses in debug mode
if (kDebugMode) {
print('Response: ${response.statusCode} ${response.bodyString}');
}
return response;
});
// Auth interceptor
httpClient.addAuthenticator<dynamic>((request) async {
// Refresh token if 401
final token = await refreshToken();
request.headers['Authorization'] = 'Bearer $token';
return request;
});
}
// GET request
Future<Response<List<Product>>> getProducts() {
return get<List<Product>>(
'/products',
decoder: (data) => (data as List)
.map((item) => Product.fromJson(item))
.toList(),
);
}
// POST request
Future<Response<User>> createUser(User user) {
return post<User>(
'/users',
user.toJson(),
decoder: (data) => User.fromJson(data),
);
}
// PUT request
Future<Response<User>> updateUser(String id, User user) {
return put<User>(
'/users/$id',
user.toJson(),
decoder: (data) => User.fromJson(data),
);
}
// DELETE request
Future<Response> deleteUser(String id) {
return delete('/users/$id');
}
}
Using GetConnect in Repository
class UserRepositoryImpl implements UserRepository {
final ApiProvider apiProvider;
UserRepositoryImpl({required this.apiProvider});
@override
Future<Either<Failure, List<User>>> getUsers() async {
try {
final response = await apiProvider.get<List<User>>(
'/users',
decoder: (data) => (data as List)
.map((json) => User.fromJson(json))
.toList(),
);
if (response.hasError) {
return Left(ServerFailure(response.statusText ?? 'Unknown error'));
}
return Right(response.body!);
} catch (e) {
return Left(ServerFailure(e.toString()));
}
}
}
Bindings Composition
Combine multiple bindings for complex features.
Creating Bindings
// Feature binding
class ProductBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => ProductRemoteDataSource(api: Get.find()));
Get.lazyPut(() => ProductLocalDataSource(storage: Get.find()));
Get.lazyPut<ProductRepository>(
() => ProductRepositoryImpl(
remoteDataSource: Get.find(),
localDataSource: Get.find(),
),
);
Get.lazyPut(() => GetProducts(repository: Get.find()));
Get.lazyPut(() => ProductController(getProducts: Get.find()));
}
}
// Another feature binding
class CartBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => CartLocalDataSource(storage: Get.find()));
Get.lazyPut<CartRepository>(
() => CartRepositoryImpl(localDataSource: Get.find()),
);
Get.lazyPut(() => AddToCart(repository: Get.find()));
Get.lazyPut(() => RemoveFromCart(repository: Get.find()));
Get.lazyPut(() => CartController(
addToCart: Get.find(),
removeFromCart: Get.find(),
));
}
}
// Combine bindings
class ProductDetailsBinding extends Bindings {
@override
void dependencies() {
// Register product dependencies
ProductBinding().dependencies();
// Register cart dependencies
CartBinding().dependencies();
// Register page-specific controller
Get.lazyPut(() => ProductDetailsController(
product: Get.find(),
cart: Get.find(),
));
}
}
Using BindingsBuilder
GetPage(
name: '/checkout',
page: () => CheckoutPage(),
binding: BindingsBuilder(() {
// Quick inline bindings
Get.lazyPut(() => CheckoutController());
Get.lazyPut(() => PaymentService());
Get.lazyPut(() => ShippingService());
}),
)
// Or with multiple bindings
GetPage(
name: '/checkout',
page: () => CheckoutPage(),
bindings: [
CartBinding(),
PaymentBinding(),
ShippingBinding(),
],
)
Rx Advanced Patterns
Custom Rx Classes
class RxUser extends Rx<User> {
RxUser(User initial) : super(initial);
String get fullName => value.firstName + ' ' + value.lastName;
bool get isAdmin => value.role == 'admin';
void updateEmail(String email) {
value = value.copyWith(email: email);
}
}
// Usage
final user = RxUser(User(firstName: 'John', lastName: 'Doe'));
user.updateEmail('[email protected]');
Rx Transformations
class DataController extends GetxController {
final rawData = <Item>[].obs;
// Computed observable
List<Item> get filteredData => rawData.where((item) => item.isActive).toList();
// Or use Rx.map
late final activeItems = rawData.map((data) =>
data.where((item) => item.isActive).toList()
).obs;
}
GetQueue - Background Tasks
class UploadQueue extends GetxService {
final _queue = <UploadTask>[].obs;
void addTask(UploadTask task) {
_queue.add(task);
processQueue();
}
Future<void> processQueue() async {
while (_queue.isNotEmpty) {
final task = _queue.first;
try {
await _uploadFile(task);
_queue.removeAt(0);
} catch (e) {
// Retry logic
task.retryCount++;
if (task.retryCount >= 3) {
_queue.removeAt(0); // Give up after 3 retries
} else {
await Future.delayed(Duration(seconds: task.retryCount * 2));
}
}
}
}
Future<void> _uploadFile(UploadTask task) async {
// Upload logic
}
}
Testing GetX Controllers
Unit Testing
void main() {
late ProductController controller;
late MockGetProducts mockGetProducts;
setUp(() {
mockGetProducts = MockGetProducts();
controller = ProductController(getProducts: mockGetProducts);
});
tearDown(() {
controller.dispose();
});
test('loads products successfully', () async {
// Arrange
final products = [Product(id: '1', name: 'Product 1')];
when(() => mockGetProducts())
.thenAnswer((_) async => Right(products));
// Act
await controller.loadProducts();
// Assert
expect(controller.products, products);
expect(controller.isLoading, false);
verify(() => mockGetProducts()).called(1);
});
test('handles failure when loading products', () async {
// Arrange
when(() => mockGetProducts())
.thenAnswer((_) async => Left(ServerFailure('Error')));
// Act
await controller.loadProducts();
// Assert
expect(controller.products, isEmpty);
expect(controller.error, isNotNull);
expect(controller.isLoading, false);
});
}
Widget Testing with GetX
testWidgets('displays products when loaded', (tester) async {
// Mock controller
final controller = ProductController(getProducts: mockGetProducts);
Get.put(controller);
// Pump widget
await tester.pumpWidget(
GetMaterialApp(
home: ProductListPage(),
),
);
// Simulate loading
controller.products.value = [Product(id: '1', name: 'Product 1')];
await tester.pumpAndSettle();
// Assert
expect(find.text('Product 1'), findsOneWidget);
// Cleanup
Get.delete<ProductController>();
});
Best Practices
-
Workers:
- Use
debouncefor search inputs (800ms delay) - Use
everfor side effects (analytics, navigation) - Use
oncefor one-time actions (onboarding) - Use
intervalfor periodic updates (30s+) - Always dispose workers in
onClose()
- Use
-
GetxService:
- Use for app-wide services (Auth, Theme, Localization)
- Register with
permanent: true - Initialize async operations in
init()method - Services should be stateless or immutable where possible
-
SmartManagement:
- Stick with
SmartManagement.full(default) for most apps - Only change if you have specific memory management needs
- Document why you're using non-default management
- Stick with
-
GetConnect:
- Create one
GetConnectinstance per API - Use interceptors for auth, logging, error handling
- Implement retry logic in
addAuthenticator - Handle errors consistently in repositories
- Create one
-
Bindings:
- One binding per feature/route
- Use
lazyPutfor dependencies (loaded when first used) - Use
putfor singletons needed immediately - Compose bindings for complex features
-
Testing:
- Always dispose controllers in
tearDown() - Use
Get.reset()to clear all dependencies between tests - Mock use cases, not repositories
- Test reactive state changes with
pumpAndSettle()
- Always dispose controllers in