Flutter GetX Tutorial: State, Routing, and Dependency Injection in One Lightweight Package
Step-by-step Flutter GetX tutorial covering state, routing, DI, bindings, workers, theming, and i18n with practical code snippets.
Image used for representation purposes only.
Overview
GetX is a lightweight, batteries‑included Flutter package that unifies three pillars developers need in almost every app:
- State management
- Routing/navigation
- Dependency injection (DI)
Its appeal is minimal boilerplate, excellent performance, and a consistent API. In this tutorial, you’ll set up GetX from scratch, learn both reactive and simple state patterns, configure routing with bindings, inject services, and add niceties like snackbars, dialogs, workers (debounce, interval), theming, and translations.
Prerequisites and Setup
- Flutter installed (stable channel)
- Basic Dart/Flutter knowledge
Add GetX to your project:
flutter pub add get
Replace MaterialApp with GetMaterialApp to enable GetX features globally:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'GetX Tutorial',
debugShowCheckedModeBanner: false,
home: const HomePage(),
);
}
}
The Three Pillars at a Glance
- State: Choose between simple (GetBuilder) and reactive (Obx/Rx) approaches.
- Routing: Navigate with Get.to, Get.offAllNamed, and configure pages via GetPage.
- DI: Register and retrieve controllers/services with Get.put, Get.lazyPut, and Get.find.
State Management: Two Approaches
1) Reactive state with Obx
Best when you want fine‑grained reactivity without manual “notify” calls.
import 'package:get/get.dart';
class CounterController extends GetxController {
final count = 0.obs; // RxInt
void increment() => count.value++;
}
UI:
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final c = Get.put(CounterController()); // register once
return Scaffold(
appBar: AppBar(title: const Text('Reactive Counter')),
body: Center(
child: Obx(() => Text(
'Count: ${c.count}',
style: const TextStyle(fontSize: 32),
)),
),
floatingActionButton: FloatingActionButton(
onPressed: c.increment,
child: const Icon(Icons.add),
),
);
}
}
Key points:
- .obs turns primitives and objects into reactive streams.
- Obx listens and rebuilds only the affected widget subtree when c.count changes.
2) Simple state with GetBuilder
Great for one‑off rebuilds and maximum performance without streams.
class SimpleCounterController extends GetxController {
int count = 0;
void increment() {
count++;
update(); // Triggers GetBuilder rebuild
}
}
class SimpleCounterPage extends StatelessWidget {
const SimpleCounterPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<SimpleCounterController>(
init: SimpleCounterController(),
builder: (c) => Scaffold(
appBar: AppBar(title: const Text('Simple Counter')),
body: Center(child: Text('Count: ${c.count}', style: const TextStyle(fontSize: 32))),
floatingActionButton: FloatingActionButton(
onPressed: c.increment,
child: const Icon(Icons.add),
),
),
);
}
}
Tip: With GetBuilder you can pass an id to update specific parts: update([‘price’]); for granular rebuilds.
Routing and Navigation with GetX
Define pages and named routes
class AppRoutes {
static const home = '/';
static const details = '/details';
}
class AppPages {
static final pages = [
GetPage(name: AppRoutes.home, page: () => const HomePage(), binding: HomeBinding()),
GetPage(name: AppRoutes.details, page: () => const DetailsPage()),
];
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
getPages: AppPages.pages,
initialRoute: AppRoutes.home,
);
}
}
Navigate
// Push
Get.to(const DetailsPage());
Get.toNamed(AppRoutes.details, arguments: {'id': 42});
// Replace current
Get.off(const DetailsPage());
// Clear stack and go
Get.offAllNamed(AppRoutes.home);
Receive arguments
class DetailsPage extends StatelessWidget {
const DetailsPage({super.key});
@override
Widget build(BuildContext context) {
final args = Get.arguments as Map?; // e.g., {'id': 42}
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: Center(child: Text('ID: ${args?['id'] ?? 'none'}')),
);
}
}
Dependency Injection and Bindings
Bindings let you declare dependencies for a route once, avoiding Get.put in build methods.
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<CounterController>(() => CounterController());
Get.put<ApiService>(ApiService(), permanent: true); // example service
}
}
class ApiService {
Future<String> fetchGreeting() async => 'Hello from API';
}
Retrieve dependencies anywhere:
final api = Get.find<ApiService>();
Dispose when done:
Get.delete<CounterController>();
Workers: ever, once, debounce, interval
Workers observe Rx variables and run side effects efficiently.
class SearchController extends GetxController {
final query = ''.obs;
late Worker _debouncer;
@override
void onInit() {
super.onInit();
// Fire after the user stops typing for 400 ms
_debouncer = debounce(query, (String val) {
_search(val);
}, time: const Duration(milliseconds: 400));
}
void _search(String q) {
// call API or filter list
debugPrint('Searching for: $q');
}
@override
void onClose() {
_debouncer.dispose();
super.onClose();
}
}
class SearchField extends StatelessWidget {
const SearchField({super.key});
@override
Widget build(BuildContext context) {
final c = Get.put(SearchController());
return TextField(
onChanged: (v) => c.query.value = v,
decoration: const InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'Search…'),
);
}
}
Other workers:
- ever(rx, cb): runs every time rx changes
- once(rx, cb): runs the first time rx changes
- interval(rx, cb, time): throttles rapid changes
Snackbars, Dialogs, and Bottom Sheets
// Snackbar
Get.snackbar('Saved', 'Your changes were saved', snackPosition: SnackPosition.BOTTOM);
// Dialog
Get.defaultDialog(
title: 'Delete item?',
middleText: 'This action cannot be undone.',
textConfirm: 'Delete',
textCancel: 'Cancel',
onConfirm: () {
// delete
Get.back();
},
);
// Bottom sheet
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: const Text('Bottom sheet content'),
),
);
Translations and Localization
Define a Translations class once and use .tr anywhere.
class AppTranslations extends Translations {
@override
Map<String, Map<String, String>> get keys => {
'en_US': {
'hello': 'Hello',
'greet_name': 'Hello @name',
},
'es_ES': {
'hello': 'Hola',
'greet_name': 'Hola @name',
},
};
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
translations: AppTranslations(),
locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'),
home: const I18nPage(),
);
}
}
class I18nPage extends StatelessWidget {
const I18nPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('hello'.tr)),
body: Center(
child: Text('greet_name'.trParams({'name': 'Sam'})),
),
);
}
}
Theming and Dark Mode
Switch theme at runtime without rebuilding the whole app tree manually.
ElevatedButton(
onPressed: () => Get.changeTheme(Get.isDarkMode ? ThemeData.light() : ThemeData.dark()),
child: const Text('Toggle Theme'),
)
Or manage ThemeMode in a controller:
class ThemeController extends GetxController {
final mode = ThemeMode.system.obs;
void toggle() => mode.value = mode.value == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final t = Get.put(ThemeController());
return Obx(() => GetMaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: t.mode.value,
home: const HomePage(),
));
}
}
Lifecycle Hooks You’ll Actually Use
- onInit: start listeners, workers, initial fetch
- onReady: called after first frame; good for dialogs/snackbars
- onClose: dispose controllers, cancel timers/workers
class ProfileController extends GetxController {
late Worker _sub;
final name = ''.obs;
@override
void onInit() {
super.onInit();
_sub = ever(name, (n) => debugPrint('Name changed: $n'));
}
@override
void onClose() {
_sub.dispose();
super.onClose();
}
}
Project Structure Suggestion
- lib/
- app/
- bindings/
- controllers/
- data/ (services, repositories)
- routes/
- ui/ (pages, widgets)
- app/
Keep controllers lean; move I/O to services/repositories injected via Get.
Testing a GetX Controller
Controllers are plain Dart classes—easy to test.
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
class CounterController extends GetxController {
final count = 0.obs;
void increment() => count.value++;
}
void main() {
test('increments the count', () {
final c = CounterController();
expect(c.count.value, 0);
c.increment();
expect(c.count.value, 1);
});
}
Common Pitfalls and How to Avoid Them
- Don’t call Get.put in a build method; use Bindings or onInit to avoid multiple instances.
- Prefer Obx for reactive streams and GetBuilder for simple, manual updates.
- Dispose workers/timers in onClose to prevent memory leaks.
- If a widget doesn’t rebuild, ensure you’re using .value for Rx or calling update() for GetBuilder.
- Keep global singletons (e.g., ApiService) permanent only if truly app‑wide.
When to Choose GetX
- You want one small dependency covering state, routing, and DI.
- You need minimal boilerplate and granular rebuilds.
- You prefer controller‑centric architecture without verbose providers.
If your team already uses another state manager (e.g., Provider, Riverpod, BLoC), you can still adopt Get for routing and dialogs while keeping your state solution.
Putting It Together: Mini Feature Example
A list page navigates to details and toggles a favorite count reactively.
class ProductController extends GetxController {
final favorites = <int>{}.obs; // RxSet<int>
bool isFav(int id) => favorites.contains(id);
void toggle(int id) => favorites.toggle(id);
}
class ProductBinding extends Bindings {
@override
void dependencies() => Get.lazyPut(() => ProductController());
}
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context) {
final c = Get.find<ProductController>();
final products = List.generate(20, (i) => i);
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: Obx(() => ListView.builder(
itemCount: products.length,
itemBuilder: (_, i) => ListTile(
title: Text('Item $i'),
trailing: IconButton(
icon: Icon(c.isFav(i) ? Icons.favorite : Icons.favorite_border),
onPressed: () => c.toggle(i),
),
onTap: () => Get.to(() => ProductDetails(id: i)),
),
)),
);
}
}
class ProductDetails extends StatelessWidget {
final int id;
const ProductDetails({super.key, required this.id});
@override
Widget build(BuildContext context) {
final c = Get.find<ProductController>();
return Scaffold(
appBar: AppBar(title: Text('Item $id')),
body: Center(
child: Obx(() => IconButton(
iconSize: 64,
icon: Icon(c.isFav(id) ? Icons.favorite : Icons.favorite_border),
onPressed: () => c.toggle(id),
)),
),
);
}
}
Final Tips and Next Steps
- Start simple: one feature, one controller, and Bindings.
- Use Obx for reactive UIs and GetBuilder for precise, manual updates.
- Centralize navigation via named routes and GetPage.
- Introduce workers for search bars and live validation.
- Consider GetConnect (HTTP client) and GetStorage (lightweight key‑value) as your app grows.
With these building blocks, you can confidently structure real‑world apps on GetX: clean controllers, predictable updates, effortless navigation, and fewer rebuild headaches.
Related Posts
Flutter Web Development Guide: Build, Optimize, and Ship Fast Web Apps
A practical, end-to-end Flutter web development guide: setup, routing, responsive layouts, performance, SEO, PWAs, testing, and deployment tips.
Flutter State Management Guide: Patterns, Packages, and Practical Examples
A practical, end-to-end Flutter state management guide with patterns, packages, examples, and performance tips for building scalable, testable apps.
Flutter Firebase Authentication: A Complete, Modern Guide
A complete, pragmatic guide to Firebase Authentication in Flutter: setup, email, Google, Apple, phone, linking, security, and testing.