Write An Application With Getx
I wrote an article where I analyzed the source code of GetX. I found many things for which I wouldn’t normally use GetX in any of my projects.
But, in my Reddit post, some people said I was wrong; they were more productive and delivering faster, etc. They argued that because I haven’t used it, I can’t judge, and my opinion is useless.
That’s why I’m going to write a small project with version ^5.0.0-release-candidate-9.2.1
.
It’s a release candidate, but as the package’s author says here:
To be honest with you, version 5 is way more stable than version 4.
The app is very simple. It’s a chat app with two pages:
- Home page: A list of conversations for the user.
- Conversation page: The page where we read messages and can write new ones.
Of course, there’s no backend. All the data is fake, and if there’s an API call, it will be simulated with await Future.delayed(Duration(...))
.
Let’s start!
The Main Pages
When using the GetX package, you must use GetMaterialApp()
or GetCupertinoApp()
. They’re similar to the standard MaterialApp()
, but include the tools GetX needs to function correctly.
This is necessary for navigation. I tried using MaterialApp()
instead, and it didn’t work. The state management worked correctly with or without it, so it could probably be integrated into existing projects.
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
home: HomeScreen(),
initialRoute: "/",
getPages: [
GetPage(
name: "/",
page: () => HomeScreen(),
),
GetPage(
name: "/conversation",
page: () => ConversationScreen(),
title: "Lol",
),
],
);
}
}
It’s simple. GetX wraps the MaterialApp
with a GetRoot
and uses its own router implementation.
So far, nothing special. People using go_router
are used to this. I don’t understand some parameters, such as title
, alignment
, or preventDuplicateHandlingMode
. They aren’t documented, so I won’t try to use them.
Now, the Home page:
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Home"),
),
body: const ConversationList(),
);
}
}
class ConversationList extends StatelessWidget {
const ConversationList({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 100, // Added itemCount for a finite list (adjust as needed)
itemBuilder: (context, index) => ListTile(
title: Text("Conversation ${index + 1}"),
leading: CircleAvatar(
backgroundColor: Colors.primaries[Random().nextInt(Colors.primaries.length)], // Use Random() instead of random
),
onTap: () {
Get.toNamed("/conversation");
},
),
);
}
}
It generates a list of conversations. When tapping one, it navigates to the /conversation
route.
The navigation API is simple and similar to the Navigator
API. It’s not significantly different from other popular navigation solutions, except that it doesn’t use the context.
In my opinion, it’s not more or less convenient than go_router
. It requires a similar amount of code, but I don’t know if GetX supports deep linking. This is a crucial feature for many apps. Usually, after signing in, users receive an email with a URL. Tapping it should open the app and automatically validate their code.
This would be a significant problem for many apps if they lacked this feature. They would have to use go_router
, requiring the standard MaterialApp
. This means completely rewriting the app, changing the state management, and everything related to GetX. A potential (though untested) quick fix might be:
GetMaterialApp(
home: MaterialApp.router(), //This is likely incorrect and won't work as intended.
)
After checking the repo’s issues, it seems deep linking is possible, so that’s not a problem.
The second page is the conversation page. It will simulate an API call and generate random messages. At the bottom, there will be a text field and a button to add messages to the list.
Creating the Chat
Let’s first create the state!
class ConversationMessage {
final String text;
final bool isLeft;
ConversationMessage(this.text, this.isLeft);
}
class ConversationController extends GetxController {
final messages = <ConversationMessage>[].obs;
final currentState = Rx<ConversationState>(ConversationState.loading);
@override
void onInit() {
loadMessages();
super.onInit();
}
Future<void> loadMessages() async {
// Simulate an API call
await Future.delayed(const Duration(milliseconds: 1000));
messages.value = List.generate(20, (i) => ConversationMessage(
faker.lorem.sentences(4).join("\n"),
Random().nextBool(), // Use Random() instead of random
));
currentState.value = ConversationState.loaded;
update();
}
}
The GetXController
contains the state. You define all necessary variables; they can be ints, strings, lists, maps, etc.
I have a method to generate a list of messages. The messages are stored in messages
, an RxList
. It automatically updates the state and triggers rebuilds. There’s also the update()
function, which also updates the state.
It’s unclear which to use. It seems that if I use update()
, I don’t need .obs
or RxList
. I’ll investigate the best approach later.
Now, to link the controller/state to the widget, we use a binding.
class ConversationBinding extends Bindings { // Corrected to Bindings
@override
void dependencies() {
Get.lazyPut<ConversationController>(() => ConversationController());
}
}
I don’t know why we have to do this, but it’s in the documentation and examples. If it works, it works; I can figure it out later.
In my state, I want to know if I’m loading, encountering an error, or if it’s loaded. I can’t define a separate state class for each state like in BLoC. We have one controller, and it seems we can’t replace it. So I’ll use an enum:
enum ConversationState {
loading,
error,
loaded
}
And
class ConversationController extends GetxController {
// ...
final currentState = Rx<ConversationState>(ConversationState.loading);
// ...
}
The other Rx shortcuts don’t work with enums, so I have to use Rx<>()
.
Okay, now we can write the page.
class ConversationScreen extends StatelessWidget {
const ConversationScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Conversation")),
body: GetBuilder<ConversationController>( //Use GetBuilder for simpler scenarios
builder: (controller) {
return switch (controller.currentState.value) {
ConversationState.loading => const Center(child: CircularProgressIndicator()),
ConversationState.error => const Text("Error"),
ConversationState.loaded => ChatInterface(messages: controller.messages),
};
},
),
);
}
}
The body uses a GetBuilder
widget. It finds the controller and passes it to the builder. The builder extracts the value and passes it to a switch that returns the correct widget.
At this step, I was a bit lost. The documentation says we can use GetX<>()
or Obx()
. GetBuilder
is simpler and often preferred for this case. GetX<>()
is better if you need access to the controller in the builder, while Obx()
is only useful for observing a single reactive variable. Using GetBuilder
is the better practice in this context. Passing a new ConversationController()
to init
in GetX
is generally inefficient, as you pointed out. Using Get.put
or Get.find
combined with GetBuilder
or Obx
is often the better approach for managing controllers in GetX.
There’s another way to pass a controller using the binding written earlier. We pass it to the route defined at the start like this.
... other routes
GetPage(
name: "/conversation",
page: () => ConversationScreen(),
binding: ConversationBinding(),
),
... other routes
I still don’t understand the magic, but it works. I can remove the init: Controller()
.
So far, I’m really confused by the state management. There are many different widgets or classes that seem to do the same thing. There are four different widgets to consume the controller. Some seem more memory-efficient than others, but I don’t think it’s very relevant. At most, we save a few kilobytes. If the state is heavy, it’s because it contains a lot of data, so the memory consumption of the state class itself is negligible.
Let’s continue. I created the chat interface:
class ChatInterface extends StatelessWidget {
final RxList<ConversationMessage> messages;
const ChatInterface({super.key, required this.messages});
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: MessageList(messages: messages),
),
MessageBarInput(messages: messages),
],
);
}
}
Good practice: We split into smaller widgets. The message list first:
class MessageList extends StatelessWidget {
final RxList<ConversationMessage> messages;
const MessageList({super.key, required this.messages});
@override
Widget build(BuildContext context) {
return Obx(() { // Use Obx for better performance here.
final messagesList = messages.value; // Access the value directly.
return ListView.separated(
reverse: true,
itemCount: messagesList.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final message = messagesList[index];
final alignment = message.isLeft ? Alignment.centerLeft : Alignment.centerRight;
return Align(
alignment: alignment,
child: LayoutBuilder( //Consider using a more efficient approach for message bubbles
builder: (context, constraints) => ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth * 0.7), // Example constraint
child: DecoratedBox(
decoration: BoxDecoration(
color: message.isLeft ? Colors.grey[300] : Colors.blue[100],
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(message.text),
),
),
),
),
);
},
);
});
}
}
We use Obx()
to rebuild only when the messages
list changes. This is more efficient than GetX
.
Last step, the text field, and the UI is done.
class MessageBarInput extends StatelessWidget { // Changed to StatelessWidget
final RxList<ConversationMessage> messages;
final TextEditingController textController = TextEditingController();
MessageBarInput({super.key, required this.messages});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: textController,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
),
),
IconButton(
onPressed: () {
if (textController.text.isNotEmpty) {
final newMessage = ConversationMessage(textController.text, false);
messages.add(newMessage);
textController.clear();
}
},
icon: const Icon(Icons.send),
),
],
);
}
@override
void dispose() {
textController.dispose();
super.dispose();
}
}
I’ve made MessageBarInput
a StatelessWidget
and directly access and manipulate the messages
RxList. Remember to dispose of the textController
in the parent widget where MessageBarInput
is used, if it’s a StatefulWidget. I’ve added a null check to prevent adding empty messages. This version is more efficient and cleaner. Also, I added some basic styling to the message bubbles. Remember to import dart:math
for the Random()
object.
It’s a stateful widget because we have a TextEditingController
, and we must dispose of it to avoid memory leaks. When we press the button, I create a new message that I add to the controller.
However, it doesn’t work as expected. The issue is that you’re modifying the list directly, which doesn’t trigger a rebuild in GetX. You have to create a new list to update the reference. This is because GetX observes the reference, not the list’s contents. To address this, I modified the ConversationController
as follows:
void addMessage(ConversationMessage newMessage) {
messages.value = [...messages, newMessage]; //More efficient way to add a new message
}
This creates a new list containing the existing messages and the new message, effectively updating the reference. This approach is more efficient than creating a completely new list from scratch.
And that’s it. I have a functional app.
My Thoughts
Building an app with GetX wasn’t difficult, but it was very confusing. There are many ways to do the same thing, and the documentation is unclear about the differences. It mentions that some solutions use less RAM than others, but without benchmarks, this claim lacks value.
The biggest weakness of this library is the documentation. Almost nothing is documented. I’m not talking about a comprehensive README, but about API documentation. You have to guess and hope your guess is correct. Deep linking is a good example; I believe it’s possible because people say so, but how can I be sure? GetX uses so many of its own components that it’s difficult to understand what’s happening under the hood.
I haven’t shown it here, but the package encourages the use of GetView
. It’s a StatelessWidget
with a controller (which, if I wasn’t clear, is the state). Why use it instead of a normal StatefulWidget
? It doesn’t use less memory, and I trust the Flutter team more to provide efficient stateful widgets.
I think the API could be refactored. Some methods are public when they should probably be private.
Also, I didn’t feel faster or more productive. People say they write less boilerplate code, but I don’t think so. The state management is very similar to the Cubit
in the flutter_bloc
library. I wrote roughly the same amount of code; however, with a Cubit, I have more freedom with a family of states.
Little Benchmark
I’ll quickly compare it with flutter_bloc
to compare the speed and RAM efficiency of GetX.
flutter_bloc
: ^8.1.6get
: ^5.0.0-release-candidate-9.2.1
Both will be run with Flutter 3.24.3, on a Linux build, and in profile mode.
I only compare the state management here, nothing else.
GetX
RAM: 6,978,288 B (~6.7 MB)
Rebuild after update: 8.1909 ms
Class | Library | Total Instances | Total Size |
---|---|---|---|
BindElement | package:get/get_state_manager/src/simple/get_state.dart | 1 | 208 |
Binder | package:get/get_state_manager/src/simple/get_state.dart | 1 | 144 |
GetBuilder | package:get/get_state_manager/src/simple/get_state.dart | 1 | 128 |
_InstanceBuilderFactory | package:get/get_instance/src/extension_instance.dart | 1 | 96 |
CounterController | package:benchmark_getx/main.dart | 1 | 48 |
RouterReportManager | package:get/get_navigation/src/router_report.dart | 1 | 32 |
_GetImpl | package:get/get_core/src/get_main.dart | 1 | 32 |
HomePage | package:benchmark_getx/main.dart | 1 | 32 |
MainApp | package:benchmark_getx/main.dart | 1 | 32 |
SmartManagement | package:get/get_core/src/smart_management.dart | 1 | 32 |
Notifier | package:get/get_state_manager/src/simple/list_notifier.dart | 1 | 16 |
Flutter Bloc
RAM: 6,725,280 B (~6.4 MB)
Rebuild after update: 9.0429 ms
Class | Library | Total Instances | Total Size |
---|---|---|---|
_InheritedProviderScopeElement | package:provider/src/provider.dart | 1 | 176 |
SingleChildStatefulElement | package:nested/nested.dart | 1 | 144 |
_InheritedProviderElement | package:provider/src/provider.dart | 1 | 144 |
SingleChildStatelessElement | package:nested/nested.dart | 1 | 128 |
_CreateInheritedProviderState | package:provider/src/provider.dart | 1 | 64 |
_CreateInheritedProvider | package:provider/src/provider.dart | 1 | 64 |
BlocListener | package:flutter_bloc/src/bloc_listener.dart | 1 | 64 |
BlocBuilder | package:flutter_bloc/src/bloc_builder.dart | 1 | 64 |
InheritedProvider | package:provider/src/provider.dart | 1 | 64 |
BlocProvider | package:flutter_bloc/src/bloc_provider.dart | 1 | 64 |
_Dependency | package:provider/src/provider.dart | 1 | 48 |
_BlocListenerBaseState | package:flutter_bloc/src/bloc_listener.dart | 1 | 48 |
_BlocBuilderBaseState | package:flutter_bloc/src/bloc_builder.dart | 1 | 48 |
_InheritedProviderScope | package:provider/src/provider.dart | 1 | 48 |
CounterCubit | package:benchmark_bloc/main.dart | 1 | 48 |
HomePage | package:benchmark_bloc/main.dart | 1 | 32 |
MainApp | package:benchmark_bloc/main.dart | 1 | 32 |
_DefaultBlocObserver | package:bloc/src/bloc.dart | 1 | 16 |
This very short benchmark shows that GetX uses slightly more memory but is faster in terms of rebuild time.
Does it even matter? The time to trigger a rebuild is already very fast for both. They are far faster than typical UI update rates (120 Hz), and the memory consumption difference is negligible.
This benchmark is, in my opinion, mostly useless. At best, it shows that GetX’s performance claims might need further investigation.
Conclusion
Coding with GetX isn’t drastically different from other packages. The API is similar to go_router
and flutter_bloc
’s Cubit. The documentation needs significant improvement; it’s neither clear nor straightforward.
You can likely build complex applications with GetX. The fact that it hides the use of context might be a disadvantage for some developers. However, if you deliver your product, maintain it, and add new features, then it might be a viable solution for your needs.
I still wouldn’t recommend using it. As I mentioned in my previous post, the core of the library doesn’t seem to be as well-designed or well-tested as other alternatives. We usually choose well-maintained packages, especially when the entire application relies on it.
Ultimately, the choice is yours.