Flutter. GetX State Management
Table of contents
- 1. How GetX works
- The difference between Get.lazyPut() and Get.put()
- 2. Two types of State Management
- 3. Simple State Management
- The complete example of the CounterView with GetBuider:
- Using id and tag to update specific GetBuilder(s)
- 4. Reactive State Management
- Refresh()
- Full view Counter app example with Obx that uses a controller:
- The example Counter app with Obx without a controller:
- The Complete Counter app view example with the GetX widget:
- 5. Multiple controllers on one page
- 6. Lifecycle of GetxController
- Example of lifecycle methods usage:
- 7. Autodisposal of controllers
- 8. Persistent controllers
- 9. Workers
- 10. State with composite model
- A Simple state that uses a mutable model
- Reactive state with a mutable model
- Simple state with an immutable model
- Reactive state with immutable model
- 11. Avoiding StatefulWidgets
- Counter app with ObxValue example
- 12. Advantages of using GetX 😊
- 13. Disadvantages of using GetX 😣
- 14. Best practices 💪
- 15. Links and credits
- Source code for the above examples 👨💻
- GetX official documentation 👮♀️
- Very good articles about GetX 👍
- My articles about GetX 😀
Originally published on Medium
Recently I spotted another article from FlutterDev on “Why we should not use GetX”. This topic usually attracts new authors as it lets them gain a lot of cheap likes with low effort given the known community bias against GetX.
This particular article was a bit better than others since the author actually built a chat application using GetX. So, it wasn’t a low effort, after all. His conclusion was that it is possible to build apps with GetX (surprise!😂) but documentation is not good enough.
If you are a member, please continue, otherwise, read the full story here.
So, here is my attempt to write better (or different) documentation. I wrote a lot about GetX before. I will link my other articles at the end and I will also link official documentation and several good articles written by others.
1. How GetX works
GetX is very different from other state management solutions in Flutter. Unlike BLoC, Provider or Riverpod GetX also manages routing and has a built-in Service Locator mistakenly called Dependency Injection by the Flutter community members.
The fact that GetX manages all three things (state management, routing, and service locator) together opens the way to very interesting possibilities, the most interesting of which is effective memory management.
When the route (GetPage) is added to the navigation stack
GetPage(
name: _Paths.JOKE,
page: () => const JokeView(),
binding: JokeBinding(),
),
two objects are created: View (Screen widget) and Binding.
Binding creates an instance of Controller and puts it in Service Locator:
class JokeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<JokeController>(
() => JokeController(),
);
}
}
Therefore, the Controller is easily accessible by View through the Service Locator.
But the most interesting part is here:
When the route is removed from the navigation stack, the Controller is disposed automatically.
IMHO the idea is brilliant. The Flutter community should sing dithyrambs to the GetX author and carry him in their arms, but everything happens in the opposite direction. ??
Gif displaying somebody very surprised by inadequate behavior
Okay, this is supposed to be documentation. No jokes, only serious stuff.
The difference between Get.lazyPut() and Get.put()
The Service Locator has two main methods: lazyPut()
and put()
.
lazyPut
example:
class JokeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<JokeController>(
() => JokeController(),
);
}
}
put
example:
class JokeBinding extends Bindings {
@override
void dependencies() {
Get.put<JokeController>(
JokeController(),
);
}
}
put
inserts the instance of the Controller into the Service Locator.
lazyPut
inserts the function (builder) into the Service Locator. When Get.find<JokeController>
is called the first time Service Locator will use the function to create the instance.
We got an overview of how GetX works, let’s dive into the details.
Worth noting, that there is no need to create all the above-mentioned classes (GetPage, GetView, Bingding, and GetxController
) manually. They should be generated by the get_cli.
2. Two types of State Management
GetX has two types of state management: Simple and Reactive.
Both of them use GetxController as a parent class for controllers. However, consumer widgets and controller syntax differ.
Simple State Management should be our default, as it is more performance-friendly.
Reactive State Management should be used for special cases like working with streams and creating custom components with the local state.
3. Simple State Management
The controller that used with the GetBuilder widget should be written as follows:
class CounterGetbuilderController extends GetxController {
int _state = 0;
int read() {
return _state;
}
void increment() {
_state++;
update(); //here
}
}
GetxController has an
update
method that notifies GetBuilder(s) that the state has changed.
Making the state private is optional but can be considered a good practice since it encourages reading/updating through the methods and not directly.
Here is an example of GetBuilder:
GetBuilder<CounterGetbuilderController>(
builder: (controller) => Text(
controller.read()
),
),
It is possible to instantiate the Controller inside GetBuilder:
GetBuilder<CounterGetbuilderController>(
init: CounterController(),
builder: (controller) => Text(
controller.read()
),
),
I am not familiar with the use case where this feature is useful. For me, it is always better to use get_cli to generate the route, the view, the controller, and the binding.
There is always only one instance of a particular GetxController inside the Service Locator.
On the contrary, there may be several GetBuilders associated with the same instance of the GetxController. When update()
is called all GetBuiders get updated and rebuild the widget tree inside them.
How to update the state? Simple:
ElevatedButton(
onPressed: Get.find<CounterGetbuilderController>().increment,
child: Text('Increment',),
),
The complete example of the CounterView with GetBuider:
class CounterGetbuilderView extends StatelessWidget {
const CounterGetbuilderView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('CounterGetbuilderView'),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GetBuilder<CounterGetbuilderController>(
builder: (controller) => Text(controller.read().toString()),
),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: Get.find<CounterGetbuilderController>().increment,
child: Text(
'Increment',
),
),
],
),
),
);
}
}
Note, that we have used Get.find
inside ElevatedButton
to get a reference to the controller.
GetX package has a GetView
widget that calls Get.find
internally.
Here is an example of the Counter app using GetView
:
class CounterGetbuilderView extends GetView<CounterGetbuilderController>{
const CounterGetbuilderView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('CounterGetbuilderView'),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GetBuilder<CounterGetbuilderController>(
builder: (ctrl) => Text(ctrl.read().toString()),
),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: controller.increment,
child: Text(
'Increment',
),
),
],
),
),
);
}
}
Note the controller
variable inside ElevatedButton
. It is provided by GetView
.
Using id
and tag to update specific GetBuilder(s)
GetBuilder has id
and tag
properties that can be used to refine the control over rebuilding.
Let’s imagine that we have a page with several GetBuilders that belong to the same Controller. If we want to update only one of them we can submit the id parameter into the constructor:
GetBuilder<CounterGetbuilderController>(
id: 'counter'
builder: (controller) => Text(
controller.read()
),
),
And then in the controller update it like this:
void increment() {
_state++;
update(['counter']); //here
}
You can also impose conditions for the update:
update(['counter'], counter < 10);
In case we want to update several GetBuilders (but not all of them) the tag
property can be used instead of id
:
GetBuilder<CounterGetbuilderController>(
tag: 'counter'
builder: (controller) => Text(
controller.read()
),
),
The code in the controller is the same.
4. Reactive State Management
Reactive State Management has several differences from simple one:
.obs
extension method creates an observable i.e. opens a stream generator that emits a new value every time the state value changes, therefore there is no need to callupdate();
There are two “Consumer” widgets: Obx and GetX;
Obx does not need a Controller and can observe the state directly. (It is still better practice to use Controller for separation of concerns, single responsibility, OOP, and so on.)
An example of a reactive controller:
class CounterController extends GetxController {
final _state = 0.obs;
int read() {
return _state.value;
}
void increment() {
_state.value++;
}
}
GetX package has its own implementation of RxDart and the _state
here is an observable. The type of the _state
is RxInt
.
As we can see there is no need to call update()
and hence, we may not need a Controller, but just a [local] variable:
var state = 0.obs;
It is not the recommended way. The better practice is to read/update the state through the methods (aka UDF) unless we make a custom component with a local state.
Refresh()
The above works fine for “primitives” however, for composite objects and collections, we may need to call refresh()
to update the state:
class UserController extends GetxController {
final user = User().obs;
void updateUser(int age) {
user.value.age = age;
user.refresh();
}
}
An example of an Obx
widget:
child: Obx(() => Text(
Get.find<CounterController>()._state.value,
),
),
Or without a controller (only inside the custom component):
child: Obx(() => Text(
state.value,
),
),
An example of a GetX widget:
GetX<CounterController>(
init: Get.put<CounterController>(CounterController()),
builder: (controller) {
return Text( '${controller.counter}');
},
)
Same way as with GetBuilder, the init
parameter is optional:
GetX<CounterController>(
builder: (controller) {
return Text( '${controller.counter}');
},
)
Full view Counter app example with Obx that uses a controller:
class CounterReactiveView extends GetView<CounterReactiveController> {
const CounterReactiveView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('CounterReactiveView'),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Obx(() => Text(
controller.read().toString(),
),
),
ElevatedButton(
onPressed: controller.increment,
child: Text(
'Increment',
),
),
],
),
),
);
}
}
The example Counter app with Obx without a controller:
class CounterReactiveViewNoController extends StatelessWidget {
CounterReactiveViewNoController({super.key});
final _state = 0.obs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('CounterReactiveView no controller'),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Obx(
() => Text(
_state.value.toString(),
),
),
ElevatedButton(
onPressed: () {
_state.value++;
},
child: Text(
'Increment',
),
),
],
),
),
);
}
}
As you can see we just defined the _state
as a widget's property. This way we can create any kind of custom components that need local state: Switch
, NavigationBar
, SegmentedButton
, and so on. It is better than using StatefulWidget
since we have more control over rebuilding.
The Complete Counter app view example with the GetX widget:
class CounterReactiveGetxView extends GetView<CounterReactiveGetxController> {
const CounterReactiveGetxView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('CounterReactiveView with GetX'),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GetX<CounterReactiveGetxController>(
init: Get.put<CounterReactiveGetxController>(CounterReactiveGetxController()),
builder: (controller) {
return Text( '${controller.read()}');
},
),
ElevatedButton(
onPressed: controller.increment,
child: Text(
'Increment',
),
),
],
),
),
);
}
}
Unlike Obx
, GetX
should be parameterized with the controller type and has an optional init
parameter.
The only good use case for GetX I can think of is multiple controllers on one page.
5. Multiple controllers on one page
Consider the ToDo app. Practically on every page, we can have two controllers:
TodoController
AuthController
Hence, it can be a good use case for the GetX
widget or GetBuilder .
We will get controller
variable which is a reference to AuthController
from GetView<AuthController>
and then we get a reference to TodoController
inside GetX<TodoController>
(or GetBuilder<TodoController>
).
While this is a valid way to organize code, I prefer to keep one controller per page. Consider the diagram below:
Instead of having two controllers where each has a related service as a dependency, I use one controller that holds references to both services.
It is obviously not that Black and White that one controller is always better than multiple. I just prefer things this way when it is possible.
Therefore, I never use the GetX widget and always use Obx instead.
6. Lifecycle of GetxController
GetxController has three lifecycle methods:
onInit()
: It is called immediately after the widget is allocated memory.
onReady()
: It is called immediately after the widget is rendered on screen.
onClose()
: It is called just before the controller is deleted from memory.
class TestController extends GetxController {
@override
void onInit() {
super.onInit();
}
@override
void onReady() {
super.onReady();
}
@override
void onClose() {
super.onClose();
}
}
Example of lifecycle methods usage:
class AuthController extends GetxController {
final AuthService authService;
AuthController({required this.authService});
final Rx<fauth.User?> _firebaseUser =
Rx<fauth.User?>(fauth.FirebaseAuth.instance.currentUser);
fauth.User? get user => _firebaseUser.value;
@override
onInit() {
_firebaseUser.bindStream(authService.authStateChanges());
super.onInit();
}
@override
onClose() {
_firebaseUser.close();
super.onClose();
}
...
The above controller binds the stream onInit
and closes it onClose
. I don’t have an example of onReady
usage yet.
7. Autodisposal of controllers
Here is an application with multiple Counters screens.
As we can see when the screen is removed from the navigation stack (the user clicks the back button) the controller is disposed of (deleted from memory).
We can see it by [GetX] logs and also by observing counters — they always start from 0 when the screen is navigated to.
Before the controller is deleted its onClose
method is called — allowing to dispose of Text and Animation controllers, cancel subscriptions to streams, stop timers, and so on.
8. Persistent controllers
GetX allows us to make controllers persistent — such controllers will not be autodisposed.
Above mentioned AuthController
and TodoController
are good candidates to become persistent.
To make the controller persistent we should adjust the code in Binding
class:
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.put<AuthController>(
AuthController(), permanent: true
);
}
}
We replaced lazyPut
with put,
made the first parameter an instance of the controller (instead of a function) and added permanent:true
parameter.
Now, every time we call the Get.find<AuthController>
we will get the same instance. AuthController will live while the app is living and will not be autodisposed.
9. Workers
Workers are listeners that listen to the state changes.
class CounterReactiveGetxController extends GetxController {
RxInt count1 = 0.obs;
int read() {
return count1.value;
}
void increment() {
count1.value++;
}
Worker? ever_;
Worker? once_;
Worker? debounce_;
Worker? interval_;
@override
void onInit() {
super.onInit();
ever_ = ever(count1, (_) => print("ever: count1 has been changed to $_"));
once_ = once(count1, (_) => print("once: count1 has been changed to $_"));
debounce_ = debounce(
count1, (_) => print("debouce: count1 has been changed to $_"),
time: Duration(seconds: 1));
interval_ = interval(
count1, (_) => print("interval count1 has been changed to $_"),
time: Duration(seconds: 1));
}
@override
void onClose() {
ever_!.dispose();
once_!.dispose();
debounce_!.dispose();
interval_!.dispose();
}
}
This is what has been printed:
flutter: ever: count1 has been changed to 1
flutter: once: count1 has been changed to 1
flutter: ever: count1 has been changed to 2
flutter: ever: count1 has been changed to 3
flutter: ever: count1 has been changed to 4
flutter: ever: count1 has been changed to 5
flutter: interval count1 has been changed to 1
flutter: ever: count1 has been changed to 6
flutter: ever: count1 has been changed to 7
flutter: ever: count1 has been changed to 8
flutter: ever: count1 has been changed to 9
flutter: ever: count1 has been changed to 10
flutter: interval count1 has been changed to 6
flutter: debouce: count1 has been changed to 10
Ever — is called every time the state changes.
Once — is called only the first time.
Debounce —is called when the state stops changing for a given duration.
Interval — is called once for a given duration.
Every worker returns worker
instance that should be used to call dispose
.
10. State with composite model
Before we only used examples with int.
Other “primitives” (bool
, String
, etc.) will work analogically.
However, composite objects are different, especially in the reactive state.
When we work with composite models we have two choices:
Mutable model
Immutable model
The immutable model offers more maintainability in exchange for possible performance fines.
Let’s start with a mutable one:
class MutableUser {
String _name;
int _age;
MutableUser({name, age})
: _name = name,
_age = age;
String get name => _name;
int get age => _age;
MutableUser changeWith({String? name, int? age}) {
_name = name ?? _name;
_age = age ?? _age;
return this;
}
}
A Simple state that uses a mutable model
Controller:
class MutableSimpleController extends GetxController {
var _state = MutableUser(name: 'John', age: 45);
MutableUser read() {
return _state;
}
void change(MutableUser val) {
_state = val;
update();
}
}
View:
GetBuilder<MutableSimpleController>(
builder: (ctrl) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(ctrl.read().name),
SizedBox(height: 20),
Text(ctrl.read().age.toString()),
],
);
},
),
...
ElevatedButton(
onPressed: () {
var state = controller
.read()
.changeWith(name: "Jim", age: controller.read().age + 1);
controller.change(state);
},
Reactive state with a mutable model
Controller:
class MutableReactiveController extends GetxController {
var _state = MutableUser(name: 'John', age: 45).obs;
MutableUser read() {
return _state.value;
}
void change(MutableUser val) {
_state.value = val;
_state.refresh();
}
}
Notice, that we call refresh
on the _state
object.
View (notice Obx
instead of GetBuilder
):
Obx(() => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(controller.read().name),
SizedBox(height: 20),
Text(controller.read().age.toString()),
],
)
),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: () {
var state = controller
.read()
.changeWith(name: "Jim", age: controller.read().age + 1);
controller.change(state);
},
child: Text(
'Increment аgе',
),
),
Simple state with an immutable model
Model:
class ImmutableUser {
final String name;
final int age;
ImmutableUser({required this.name, required this.age});
ImmutableUser copyWith({String? name, int? age}) {
return ImmutableUser(
name: name ?? this.name,
age: age ?? this.age,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmutableUser &&
other.name == name &&
other.age == age;
}
@override
int get hashCode => name.hashCode ^ age.hashCode;
}
Controller:
class ImmutableSimpleController extends GetxController {
var _state = ImmutableUser(name: 'John', age: 45);
ImmutableUser read() {
return _state;
}
void change(ImmutableUser val) {
_state = val;
update();
}
}
View:
GetBuilder<ImmutableSimpleController>(
builder: (ctrl) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(ctrl.read().name),
SizedBox(height: 20),
Text(ctrl.read().age.toString()),
],
);
},
),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: () {
var state = controller
.read()
.copyWith(name: "Jim", age: controller.read().age + 1);
controller.change(state);
},
child: Text(
'Increment аgе',
),
),
Reactive state with immutable model
Controller:
class ImmutableReactiveController extends GetxController {
var _state = ImmutableUser(name: 'John', age: 45).obs;
ImmutableUser read() {
return _state.value;
}
void change(ImmutableUser val) {
_state.value = val;
// _state.refresh(); //here
}
}
Note, that we don’t need to call refresh
when working with immutable models.
View:
Obx(() => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(controller.read().name),
SizedBox(height: 20),
Text(controller.read().age.toString()),
],
)
),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: () {
var state = controller
.read()
.copyWith(name: "Jim", age: controller.read().age + 1);
controller.change(state);
},
child: Text(
'Increment аgе',
),
),
11. Avoiding StatefulWidgets
There are two main cases for StatefulWidget:
Custom components with local (ephemeral) state.
Dispose of resources (TexEditing and Animation controllers, stream subscriptions, etc.).
GetX allows us to completely avoid the usage of StatefulWidget.
Custom components can be created with StatelessWidget which contains 0.obs
state and Obx
widget.
Resources can be moved to an auto-disposable Controller.
GetX also has two “secret” widgets: ValueBuilder
and ObxValue
created specifically to replace StatefulWidgets. Seems like very few people use them.
Counter app with ObxValue example
class CounterObxValueView extends StatelessWidget {
const CounterObxValueView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Counter with ObxValue'),
centerTitle: true,
),
body: Center(
child: ObxValue(
(data) => Column(
children: [
Text(data.toString()),
SizedBox(
height: 40,
),
ElevatedButton(
onPressed: () => data++,
child: Text(
'Increment',
),
),
],
),
0.obs,
),
));
}
}
It’s not shorter than the Obx
version with the local state, so I don’t really see the reason to use it.
12. Advantages of using GetX 😊
Autodispose of controllers.
Possibility to avoid StatefulWidgets.
Concise and readable syntax.
Flexibility.
Simple state management is slightly more performance-friendly than other well-known solutions like Riverpod, Provider, or BLoC.
Beginner friendly.
Write less — do more. GetX has a lot of shortcuts.
Learning GetX's ways of doing things will give you another perspective and make you a better developer.
You can make friends. I found that people who have positive or at least balanced opinions about GetX are generally more adequate than those who hate it.
13. Disadvantages of using GetX 😣
No built-in fool (junior) proof.
The same things can be done in many different ways.
Self-discipline is required.
GetX is hated by the community. Revealing publicly that you are using GetX will give you a lot of downvotes on FlutterDev.
If you want a paid Flutter job, you may want to learn Riverpod and BLoC (and GoRouter and Auto_route) instead.
14. Best practices 💪
Use get_cli to create a folder structure with GetXPattern.
Always use named routes.
Read/update the state using the methods whenever possible.
Decouple the controller from the service using the Dependency Injection pattern or the Service Locator pattern. (If an app has more layers like repositories, use cases, etc. — decouple them as well).
Try to keep the View: Controller relationship as 1:1.
Avoid StatefulWidgets.
If you are sure that your app needs immutability — use immutable models with the
copyWith
method, otherwise use mutable models with thechangeWith
method.
15. Links and credits
Source code for the above examples 👨💻
GetX official documentation 👮♀️
Dependency management
Routing
State management
Very good articles about GetX 👍
The Flutter GetX Ecosystem ~ Dependency Injection
The Flutter GetX Ecosystem ~ State Management
My articles about GetX 😀
The Routing with GetX. Do it right.
Flutter, GetX: StatelessWidget vs GetView vs GetWidget
Flutter Getx: GetBuilder vs Obx vs GetX
Flutter. Change theme dynamically with GetX
Flutter. Change language dynamically with GetX
Word for GetX
Flutter. GetX 5.0 breaking changes
Flutter. Navigation with GetX 5.0
Flutter. GetX: new project with get_cli
Flutter. GetX Application Architecture
Subscribe to my newsletter
Read articles from Yuriy Novikov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by