GetX in Flutter: solved state management exercise

GetX in Flutter: solved state management exercise

GetX is an all-in-one solution: reactive state management, dependency injection, and navigation without BuildContext. Its main advantage is development speed — an observable controller only rebuilds subscribed widgets, with no Provider, no StreamBuilder, no context.watch.

Problem statement

Implement a shopping cart app with GetX where:

  • The product catalog loads from a fake repository (simulates an API call).
  • The cart uses a GetxController with a reactive list.
  • A badge on the AppBar shows the number of items in real time.
  • The cart screen lists products with quantity and total.

Dependencies

1
2
3
4
5
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  get: ^4.6.6

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() {
  Get.put(CartController());           // global injection at startup
  runApp(const GetMaterialApp(home: CatalogPage()));
}

// ── Model ──────────────────────────────────────────────────────────────────────
class Product {
  final int id;
  final String name;
  final double price;

  const Product({required this.id, required this.name, required this.price});
}

// ── Repository (simulates API) ─────────────────────────────────────────────────
class ProductRepository {
  Future<List<Product>> fetchAll() async {
    await Future.delayed(const Duration(milliseconds: 600));
    return [
      const Product(id: 1, name: 'Mechanical keyboard', price: 89.99),
      const Product(id: 2, name: 'Ergonomic mouse', price: 45.00),
      const Product(id: 3, name: '27" Monitor', price: 299.00),
      const Product(id: 4, name: 'USB Headset', price: 65.50),
      const Product(id: 5, name: '7-port USB-C Hub', price: 38.00),
    ];
  }
}

// ── Cart controller ────────────────────────────────────────────────────────────
class CartController extends GetxController {
  // RxList: any widget using .obs rebuilds when the list changes
  final items = <Product>[].obs;

  int get totalItems => items.length;

  double get totalPrice => items.fold(0, (sum, p) => sum + p.price);

  void add(Product product) => items.add(product);

  void remove(Product product) => items.remove(product);

  bool contains(Product product) => items.any((p) => p.id == product.id);
}

// ── Catalog controller ─────────────────────────────────────────────────────────
class CatalogController extends GetxController {
  final _repo = ProductRepository();

  final products = <Product>[].obs;
  final isLoading = true.obs;
  final errorMessage = ''.obs;

  @override
  void onInit() {
    super.onInit();
    _loadProducts();
  }

  Future<void> _loadProducts() async {
    try {
      isLoading(true);
      errorMessage('');
      products.assignAll(await _repo.fetchAll());
    } catch (e) {
      errorMessage('Error loading products: $e');
    } finally {
      isLoading(false);
    }
  }
}

// ── Catalog screen ─────────────────────────────────────────────────────────────
class CatalogPage extends StatelessWidget {
  const CatalogPage({super.key});

  @override
  Widget build(BuildContext context) {
    // Get.put inside the screen: destroyed on exit (fenix = false by default)
    final catalog = Get.put(CatalogController());
    final cart = Get.find<CartController>();

    return Scaffold(
      appBar: AppBar(
        title: const Text('Catalog'),
        actions: [
          // Obx only rebuilds this IconButton when totalItems changes
          Obx(() => Badge(
            isLabelVisible: cart.totalItems > 0,
            label: Text('${cart.totalItems}'),
            child: IconButton(
              icon: const Icon(Icons.shopping_cart),
              onPressed: () => Get.to(() => const CartPage()),
            ),
          )),
          const SizedBox(width: 8),
        ],
      ),
      body: Obx(() {
        if (catalog.isLoading.value) {
          return const Center(child: CircularProgressIndicator());
        }
        if (catalog.errorMessage.isNotEmpty) {
          return Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(catalog.errorMessage.value),
                TextButton(onPressed: catalog._loadProducts, child: const Text('Retry')),
              ],
            ),
          );
        }
        return ListView.builder(
          padding: const EdgeInsets.all(12),
          itemCount: catalog.products.length,
          itemBuilder: (_, i) => _ProductTile(product: catalog.products[i]),
        );
      }),
    );
  }
}

class _ProductTile extends StatelessWidget {
  final Product product;
  const _ProductTile({required this.product});

  @override
  Widget build(BuildContext context) {
    final cart = Get.find<CartController>();
    return Card(
      child: ListTile(
        title: Text(product.name),
        subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
        trailing: Obx(() => cart.contains(product)
            ? FilledButton.icon(
                onPressed: () => cart.remove(product),
                icon: const Icon(Icons.remove_shopping_cart),
                label: const Text('Remove'),
                style: FilledButton.styleFrom(backgroundColor: Colors.red),
              )
            : FilledButton.icon(
                onPressed: () => cart.add(product),
                icon: const Icon(Icons.add_shopping_cart),
                label: const Text('Add'),
              )),
      ),
    );
  }
}

// ── Cart screen ────────────────────────────────────────────────────────────────
class CartPage extends StatelessWidget {
  const CartPage({super.key});

  @override
  Widget build(BuildContext context) {
    final cart = Get.find<CartController>();
    return Scaffold(
      appBar: AppBar(title: const Text('Cart')),
      body: Obx(() {
        if (cart.items.isEmpty) {
          return const Center(child: Text('Your cart is empty'));
        }
        return Column(
          children: [
            Expanded(
              child: ListView.builder(
                itemCount: cart.items.length,
                itemBuilder: (_, i) {
                  final p = cart.items[i];
                  return ListTile(
                    title: Text(p.name),
                    subtitle: Text('\$${p.price.toStringAsFixed(2)}'),
                    trailing: IconButton(
                      icon: const Icon(Icons.delete_outline),
                      onPressed: () => cart.remove(p),
                    ),
                  );
                },
              ),
            ),
            const Divider(),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('Total (${cart.totalItems} items)',
                      style: const TextStyle(fontSize: 16)),
                  Obx(() => Text(
                    '\$${cart.totalPrice.toStringAsFixed(2)}',
                    style: const TextStyle(
                        fontSize: 20, fontWeight: FontWeight.bold),
                  )),
                ],
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
              child: FilledButton(
                onPressed: () {
                  cart.items.clear();
                  Get.back();
                  Get.snackbar('Order placed', 'Thank you for your purchase!',
                      snackPosition: SnackPosition.BOTTOM);
                },
                style: FilledButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
                child: const Text('Confirm order'),
              ),
            ),
          ],
        );
      }),
    );
  }
}

How it works internally

ConceptDescription
GetxControllerBase class for controllers with lifecycle (onInit, onClose)
.obsConverts any variable into an observable (RxList, RxBool, RxString…)
Obx(() => ...)Auto-subscribes to observables used inside the builder
Get.put(T)Injects and registers an instance in the GetX container
Get.find<T>()Retrieves the registered instance from anywhere in the code
GetMaterialAppReplaces MaterialApp to enable context-free navigation and snackbars

Bindings (lazy initialization)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class HomeBinding extends Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => CatalogController());
    Get.lazyPut(() => CartController());
  }
}

// In routes:
GetPage(name: '/home', page: () => const CatalogPage(), binding: HomeBinding()),

Bindings group each route’s dependencies and destroy them on exit.

Common mistakes

  • Get.find<T>() without registering: throws "CatalogController" not found. Use Get.put or Get.lazyPut before calling Get.find.
  • Obx without observables inside: GetX warns in the console that the widget didn’t subscribe to any observable. Make sure you access .value or use .obs properties.
  • Missing GetMaterialApp: without it, Get.snackbar and context-free navigation don’t work.

Practical use

GetX is popular in apps where development speed matters more than strict architecture: prototypes, medium-sized apps, small teams. For large projects with extensive unit testing, BLoC or Riverpod offer better separation of concerns.

Guided practice and next step

FAQ

Does GetX fully replace Provider or Riverpod?

In terms of functionality, yes — it covers state, DI, and navigation. But it sacrifices testability: controllers with global Get.find are hard to isolate in unit tests. Provider and Riverpod are safer for large projects.

What is the difference between Obx and GetX?

Obx(() => ...) is the simple reactive builder. GetX<Controller>(builder: ...) gives you direct access to the controller inside the builder, useful when you need to call controller methods without Get.find.

Can I use GetX only for dependency injection without the navigation?

Yes. You can keep a normal MaterialApp and use only Get.put/Get.find for DI, without GetMaterialApp. This lets you combine GetX with your existing router (go_router, etc.).