State management in Flutter with Async Redux and Built Value

Thomas Gazzoni

7 min read

There are so many different ways to manage app state in Flutter, from the simple Statefull Widget to some more complex solutions like BloC, Mobx or Redux.

In this article we will take a look at another of the many solutions for state management in Flutter.

Table of Contents#

What is Async Redux?#

Async Redux is a reimplementation of the Redux paradigm made by Marcelo Glasberg.

As we can see, we don't have the concept of middleware and the action it self contains the reducer code, in this way we don't need to go back and forward into many files to check what a Action really does.

Maybe is not the most famous library for state management at the moment but I found it easy to use, less boilerplate code, well documented and that can be use in a real world project (so far).

Getting Started#

Let's get started and the best way to do it is with an Example, we will build a simple app with a Login and Singup page and integrate Async Redux with Built Value to it.

Firtsly we need to add some packages:

pubspec.yaml
async_redux: ^3.0.5'

# Optional but needed for this example
built_value: ^7.1.0
built_collection: ^4.3.2

Note: Async Redux dosen't need built_value package in order to work, it already provides all the functionality out of the box but I am a big fan of Built Value and I can't work without it πŸ˜‹

Project Structure#

How to organize the project strucure? this is more of a question then a answer. Async Redux give us a Recommended directory structure but based on same projects I worked on I come up with my own way to organize the files.

your_app/
└── lib/
    |── modules/
    |   |── auth/
    |   |   β”œβ”€β”€ logic/
    |   |   β”œβ”€β”€ models/
    |   |   β”œβ”€β”€ screens/
    |   |   β”œβ”€β”€ services/
    |   |   β”œβ”€β”€ store/
    |   |   └── widgets/
    |   └── others/
    |       β”œβ”€β”€ logic/
    |       β”œβ”€β”€ models/
    |       β”œβ”€β”€ screens/
    |       β”œβ”€β”€ services/
    |       β”œβ”€β”€ store/
    |       └── widgets/
    └── app_state.dart
  • modules/
    • Contains all the app functionality devided by modules, in this example we just have an auth module.
  • logic/
    • Contains the View Model logic, in other words the binding between the store and the screen.
  • models/
    • In this directory we put all the models that containes some data.
  • screens/
    • Contains all the pages belong to this module (in this example pages like Login and Signup).
  • services/
    • Contains the class and utilities for fetching data from API, hardware, native plugins, etc.
  • store/
    • This is where the real Async Redux code lives, we will have a file for the State and one for the Actions.
  • widgets/
    • All the shared widgets between the module screens will goes here (if any).
  • app_state.dart
    • The App State it wraps all the modules states and provide it to the Async Redux Store.

The State and Actions#

Let's start from the App State, the brain of our app.

modules/app_state.dart
part 'app_state.g.dart';

abstract class AppState implements Built<AppState, AppStateBuilder> {
  AppState._();
  factory AppState([updates(AppStateBuilder b)]) = _$AppState;

// Modules States
  AuthState get authState;

  /// Create a new empty App state
  factory AppState.initialState() => AppState(
        (a) => a
          ..authState.replace(AuthState()),
      );

  /// Hydrate a State from the cache
  factory AppState.fromCache({AuthState authState}) => AppState(
        (a) => a
          ..authState.replace(authState ?? AuthState()),
      );

  /// Reset all the app state
  AppState clear() {
    // Add here anything else that also needs to be carried over.
    return AppState.initialState().rebuild(
      (s) => s..authState.currentUser = authState.currentUser,
    );
  }
}

We make our AppState with Built Value so we can take advantage of the rebuild method to generate new object and make our widgets rebuild.

Now we get into our Auth module store, alas the State and Actions.

The Auth State will just have a flag tell us is the user is authenticated and the current user data.

auth/store/auth_state.dart
part 'auth_state.g.dart';

abstract class AuthState implements Built<AuthState, AuthStateBuilder> {
  static Serializer<AuthState> get serializer => _$authStateSerializer;

  AuthState._();
  factory AuthState() {
    return _$AuthState._(
      isAuthenticated: false,
    );
  }

  
  User get currentUser;

  
  bool get isAuthenticated;
}

As you can see we use Built value to define the state, in this way we can take advantage of the serializer to serialize and save the state locally in a database for example.

auth/models/user.dart
part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
  static Serializer<User> get serializer => _$userSerializer;

  User._();
  factory User([updates(UserBuilder b)]) = _$User;

  String get id;
  String get username;

  
  String get avatar;

  (wireName: 'created_at')
  int get signupTime;
}

In our user model we take advantage of Built Value to handle the mapping and serializing of data from the api.

Finally we have the Actions, let's take a look the the Login action first.

auth/store/auth_actions.dart
part 'auth_actions.g.dart';

abstract class BaseAction extends ReduxAction<AppState> {
  //
}

class AuthActionLogin extends BaseAction {
  final String username;
  final String password;
  AuthActionLogin(this.username, this.password);

  
  Future<AppState> reduce() async {
    final params = {
      'username': username,
      'password': password,
    };
    final json = await Request().post('/v1/oauth/login', params);
    final user = deserialize<User>(json);

    return state.rebuild((a) => a
      ..authState.user.replace(user)
      ..authState.isAuthenticated = true);
  }
}

What happening in here?

  • We have access to the whole app state, that means we can also easily access data of others modules if needed
  • We use the Built Value to rebuild to 'make a copy' of the whole app state while updating the part that we need (the authState in this case)

The Logic and Screens#

Let's make a rule, should we? For every Screen we have a corrispondent ViewModel in our logic folder, something like this:

auth/logic/auth_login_vm.dart
part 'auth_login_vm.g.dart';

abstract class AuthLoginVM
    implements Built<AuthLoginVM, AuthLoginVMBuilder> {
  AuthLoginVM._();
  factory AuthLoginVM([void Function(AuthLoginVMBuilder) updates]) =
      _$AuthLoginVM;

  User get currentUser;

  (compare: false)
  Future<void> Function() get submitLogin;

  static AuthLoginVM fromStore(Store<AppState> store) {
    return AuthLoginVM((viewModel) => viewModel
      ..currentUser.replace(store.state.authState.user)
      ..submitLogin = (String username, String password) {
        return store.dispatchFuture(AuthActionLogin(username, password));
      });
  }
}

Something familiar? Yes, is another Built Value model that expose some data and function from our store, with few advantages:

  • We have access to all the states data, so we can combine data from multiple states.
  • We just expose with we need, we don't need the whole app state or auth state, but just some part of it.
  • We wrap the Actions in a function, like the submitLogin, in this way the Screen doesn't need to know anything about actions and state.
  • We return a new object every time we call the fromStore method, this is what the StoreConnector needs to compute a rebuild of the widgets.
auth/screens/auth_login.dart
class AuthLoginPage extends HookWidget {
  final formKey = GlobalKey<FormState>();

  
  Widget build(BuildContext context) {
    final fieldUsername = useTextEditingController(text: '');
    final fieldPassword = useTextEditingController(text: '');

    return BasePageScaffold(
      title: 'Login',
      child: StoreConnector<AppState, AuthLoginVM>(
          converter: AuthLoginVM.fromStore,
          builder: (context, viewModel) => Form(
          key: formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              FormBox(
                type: FormBoxType.inputText,
                title: 'Username',
                validator: RequiredValidator(errorText: 'This is required'),
                controller: fieldUsername,
              ),
              FormBox(
                type: FormBoxType.inputPassword,
                title: 'Password',
                validator: RequiredValidator(errorText: 'This is required'),
                controller: fieldPassword,
              ),
              Button(
                label: 'Login',
                onPressed: () {
                  if (formKey.currentState.validate()) {
                    formKey.currentState.save();
                    viewModel.submitLogin(fieldUsername.text, fieldPassword.text).catchError(() {
                      showToast('Login failed!');
                    });
                  }
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

As you can see, the login page do not have any reference to the Store and Actions, the logic will handle that and our screen will look very clean (also because we are using Flutter Hooks πŸ‘)

Conclusion#

πŸ’š Pros#

  • No boilerplate code and simple structure.
  • Well documented and simple api.
  • Support local storage, error handling, logging and many other feature out of the box.

πŸ’” Cons#

  • If the state change frequently, there may be some overload in all the active StoreConnector.

Additional Resources#


Subscribe to the newsletter