Flutter. GetX State Management

Yuriy NovikovYuriy Novikov
16 min read

Two robbers steal GetX documentation from the office

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.

Diagram of how GetX works

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

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

Simple state management diagram

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

GetX reactive state management diagram

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 call update();

  • 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:

  1. TodoController

  2. 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:

  1. Mutable model

  2. 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:

  1. Custom components with local (ephemeral) state.

  2. 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 😊

  1. Autodispose of controllers.

  2. Possibility to avoid StatefulWidgets.

  3. Concise and readable syntax.

  4. Flexibility.

  5. Simple state management is slightly more performance-friendly than other well-known solutions like Riverpod, Provider, or BLoC.

  6. Beginner friendly.

  7. Write less — do more. GetX has a lot of shortcuts.

  8. Learning GetX's ways of doing things will give you another perspective and make you a better developer.

  9. 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 😣

  1. No built-in fool (junior) proof.

  2. The same things can be done in many different ways.

  3. Self-discipline is required.

  4. GetX is hated by the community. Revealing publicly that you are using GetX will give you a lot of downvotes on FlutterDev.

  5. If you want a paid Flutter job, you may want to learn Riverpod and BLoC (and GoRouter and Auto_route) instead.

14. Best practices 💪

  1. Use get_cli to create a folder structure with GetXPattern.

  2. Always use named routes.

  3. Read/update the state using the methods whenever possible.

  4. 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).

  5. Try to keep the View: Controller relationship as 1:1.

  6. Avoid StatefulWidgets.

  7. If you are sure that your app needs immutability — use immutable models with the copyWith method, otherwise use mutable models with the changeWith method.

Source code for the above examples 👨‍💻

GitHub repository

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

0
Subscribe to my newsletter

Read articles from Yuriy Novikov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yuriy Novikov
Yuriy Novikov