Skip to content

Migrate from Getx to Riverpod/GoRouter/Others in Flutter

Background

Getx is a good Flutter framework to resolve the different kinds of pain points, It’s a beginner-friendly framework, so beginners are more like coding on “Getx” instead of “Flutter”.

3 years ago, I jumped into Flutter for a toy-project as a backend developer and was surprised by the Getx, so simple and easy to understand/use. I migrated that toy-project from the Flutter built-in state management/router to Getx.

Now, I’m creating a new toy-project and the initial version is almost done, still based on Getx.

Before moving it to Beta testing, I’m facing the challenges:

Riverpod is a new beginner-killer lib to manage the state and dependencies. go_router, an official lib for router navigation.

As a beginner, I’ll use this article to note my migration progress, it may not cover all Getx use cases.

Also, feel free to point out my misunderstanding or mistakes, or if you have any new cases to enhance the list.

Below cases are based on this baseline implicitly:

riverpod: ^2.4.4
flutter_riverpod: ^2.4.4
riverpod_annotation: ^2.2.1
riverpod_generator: 3.0.0-dev.3
riverpod_lint: ^2.3.1

Principle

  1. Change the code page by page and the app should be runnable, always.
  2. Not rewrite all of them or recreated a new one.

Case by case

Main entry

the ProviderScope (from Riverpod for initialization) and GetMaterialApp (from Getx for initialization) can work together.

so putting them together will make the app runnable during the migration.

runApp(
ProviderScope(
child: GetMaterialApp(
...
),
),
);

Rx variables and Obx

Use NotifierProvider to replace the Rx variables, use ref.watch to replace the Obx reactive widget.

Before:

class XXXXController extends GetxController{
final RxBool _isPasswdHidden = true.obs;
bool get isPasswdHidden => _isPasswdHidden.value;
void toggle() => _isPasswdHidden.value = !_isPasswdHidden.value;
}
class XXXXWidgets extends StatelessWidget{
XXXXController c = Get.find();
get passwordTextField => Obx(() => TextFormField(
obscureText: c.isPasswdHidden,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
c.isPasswdHidden
? Icons.visibility
: Icons.visibility_off,
),
onPressed: c.toggle,
),
hintText: 'Password',
),
));
}

After:

@riverpod
class IsPasswordHidden extends _$IsPasswordHidden {
@override
bool build() => true; // same to the `true.obs` initialization
void toggle() {
state = !state;
}
}
// extending `ConsumerWidget` also works as long as the `ref` is able to refer
class XXXXWidgets extends StatelessWidget{
get passwordTextField => Consumer(
builder: (context, ref, child) => TextFormField(
// use `ref.watch` to react the change
obscureText: ref.watch(isPasswordHiddenProvider),
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(
!ref.watch(isPasswordHiddenProvider)
? Icons.visibility
: Icons.visibility_off,
),
// use `ref.read` in event function
onPressed: ref.read(isPasswordHiddenProvider.notifier).toggle
),
hintText: 'Password',
),
));
}

If use flutter_hooks with riverpod, the code can be simplified more.

Option for multiple variables

Use a class to centralize the variables and logics. It’s called State in the provider and it should be immutable. To build the immutable class, built_value is one of the options.

// use built_value to build the immutable class
abstract class MulItemsState implements Built<MulItemsState, MulItemsStateBuilder> {
MulItemsState._();
factory MulItemsState([Function(MulItemsStateBuilder b) updates]) = _$MulItemsState;
bool get isPasswordHidden;
bool get isConfirmedPasswordHidden;
factory MulItemsState.initValue() => _$MulItemsState._(
isPasswordHidden: true,
isConfirmedPasswordHidden: false,
);
}
@riverpod
class MulItems extends _$MulItems {
@override
MulItemsState build() => MulItemsState.initValue();
void togglePassword() {
state = state.rebuild((s) => s.isPasswordHidden = !s.isPasswordHidden );
}
void toggleConfirmedPassword() {
state = state.rebuild((s) => s.isConfirmedPasswordHidden = !s.isConfirmedPasswordHidden );
}
}

Service (long run instance)

Usually GetxService is used to define a long run instance. This instance may be shared by multiple pages, we can add a few lines to generate a long run riverpod provider. This provider will be used in the migration for riverpod, and existing code base can still work with GetxService.

Before:

class AuthService extends GetxService {
Future<void> logout() async {
await _auth.signOut();
}
}
class AuthPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async => await Get.find<AuthService>().logout(),
...
);
}
}

After:

// used for Riverpod
@Riverpod(keepAlive: true)
AuthService authService(AuthServiceRef ref) {
return AuthService();
}
// used for Getx, keep it if other pages are using it
class AuthService extends GetxService {
Future<void> logout() async {
await _auth.signOut();
}
}
class AuthPage extends ConsumerWidget {
@override
Widget build(BuildContext context, ref) {
return GestureDetector(
onTap: () async => await ref.read(authServiceProvider).logout(),
...
);
}
}

Class structure

Before:

the Getx controller can be referred as class attribute. to avoid too many nested widgets in the build method, I normally use getter to build small widgets and then combine them in the build.

class XXXXWidgets extends StatelessWidget{
XXXXController c = Get.find();
get passwordTextField => Obx(() => TextFormField(
// access controller
));
get usernameTextField => Obx(() => TextFormField(...));
@override
Widget build(BuildContext context) {
return Row(
children: [
usernameTextField,
passwordTextField,
]
);
}
}

After:

1.Wrap within Consumer

In riverpod, the ref (WidgetRef) is not available as class attribute, an alternative option is to wrap the widget within Consumer. However, if you have too many widgets, you have to wrap all of them.

class XXXXWidgets extends StatelessWidget{
get passwordTextField => Consumer(
builder: (context, ref, child) => TextFormField(
//access ref
)
);
get usernameTextField => Consumer(
builder: (context, ref, child) => TextFormField(...)
);
@override
Widget build(BuildContext context, ref) {
// ref is not used by those small widgets
return Row(
children: [
usernameTextField,
passwordTextField,
]
);
}
}

2.Nested functions

Dart allows you to define functions inside other functions, the inner (nested) function is private to its outer function.

The nested function can access the variables at the outer function.

class XXXXWidgets extends StatelessWidget{
@override
Widget build(BuildContext context, ref) {
// not a getter anymore
passwordTextField() => TextFormField(
// access ref
);
usernameTextField() => TextFormField(...);
return Row(
children: [
usernameTextField(), // call function
passwordTextField(),
]
);
}
}

Wrapping Consumer as getter is used in abstract class/widget and children class/widget can share the same widget. For example, the passwordTextField can be defined in an abstract widget, the children widget Login and Signup widget can use it.

Nested functions can only be accessed by outer function, can’t be shared to children classes.

Async loading

Normally, I use Getx to read the data asynchronously with job_done flag. The flag is a Rx variable, it can update the widget tree.

(this may not be the best practise, feel free to tell me the best way to use Getx for async reading)

class XXXXController extends GetxController{
final RxBool _loadingDone = false.obs;
bool get loadingDone => _loadingDone.value;
late AccountInfo info;
final UserInfoService service = Get.find();
@override
void onInit() {
super.onInit();
loadData();
}
Future<void> loadData() async {
_loadingDone.value = false;
info = await service.getAccount();
_loadingDone.value = true;
}
}
class XXXXWidgets extends StatelessWidget{
XXXXController c = Get.find();
get accountNameText => Obx(() => Visibility(
visible: c.loadingDone,
replacement: const Center(child: CircularProgressIndicator()),
child: Text(c.info.name)));
}

In riverpod, we can rely on AsyncValue with watch method to rewrite above case.

@riverpod
FutureOr<AccountInfo> accountInfo(AccountInfoRef ref) async {
return await ref.watch(userInfoServiceProvider).getAccount();
}
class XXXXWidgets extends ConsumerWidget{
@override
Widget build(BuildContext context, ref) {
final AsyncValue<AccountInfo> accountInfo = ref.watch(accountInfoProvider);
accountNameText() => accountInfo.when(
error: (e, __) => Center(child: Text('Error: ${e.toString()}')),
loading: () => const Center(child: CircularProgressIndicator()),
data: (info) => Text(info.name),
);
}
}

also can use switch instead of when on AsyncValue, like this example from the official doc.

To be continued

Updates

Created on 2023-10-24

Updates on 2023-10-26: add multiple variables case

Updates on 2023-10-26: add getxservice case

Updates on 2023-12-03: add class structure

Updates on 2023-12-05: add async loading case

Creative Commons License