MobX is a scalable library developed to ease the process of state management in frontend applications. In this tutorial, you will learn how to manage the state of your Flutter applications using MobX, and then set up a continuous integration/continuous deployment (CI/CD) pipeline for your application using CircleCI. You can find the sample project developed for this tutorial in this GitHub repository.

Prerequisites

Before getting started, you will need a working knowledge of Flutter. If you need help getting started, you can follow the codelabs on the Flutter website.

You need these items installed on your machine:

No matter which IDE you choose, you will need an installation of the Dart and Flutter plugins. These plugins are vital for editing and refactoring your Flutter application.

A short history of MobX

According to mobx.js.org, MobX is a battle tested library that makes state management simple and scalable by transparently applying functional reactive programming (TFRP). Developed primarily with React applications in mind, MobX has grown to support applications built with other JavaScript libraries, and in recent times, Flutter applications.

Using MobX for state management in Flutter apps

Managing state with MobX relies on three of the library’s main concepts:

  • Observable state
  • Actions
  • Computed values

Observable states are those properties of an application that are susceptible to change. These states are declared with the @observable annotation. For example, the observable states in a to-do application include the list of all to-dos. The list also includes every other property whose value may be updated.

Actions are operations that are aimed at changing the value of an observable state. Actions are declared with the @action annotation. Upon running an action, MobX handles updating parts of the application that use observables modified by the action. An example of an action in a to-do application would be a function to update the list of to-dos with a new to-do.

Computed values are similar to observable states and are declared with the @computed annotation. Computed values do not depend directly on actions. Instead, computed values depend on the value of observable states. If the observable state that a computed value depends on is modified by an action, the computed value is also updated. In practice, developers often overlook the idea of computed values and instead, often unintentionally, use observables in their place.

Comparing paradigms in MobX, BLoC, Redux, and setState()

MobX was built on the simple philosophy that anything that can be derived from the application state should be derived. This implies that MobX provides coverage for all properties in an application state that have been defined with the likelihood to change. MobX rebuilds the UI only when such properties change. This approach is unlike those used by BLoC, Redux, and setState. BLoC uses streams to propagate changes, while Redux is based on an application possessing a single source of truth from which its widgets inherit. Providing a similar level of simplicity as MobX, setState(), requires you to handle state propagation yourself. With its ability to abstract state-changing details, MobX provides a smoother learning curve relative to other approaches.

Setting up a Flutter project

To create your new Flutter project, you will use the Flutter CLI tool. Open your terminal and navigate to your project’s directory and run this command:

$ flutter create reviewapp

The CLI tool generates a template project that gets you started in a matter of seconds. After the project is generated, you can open it in your IDE.

Installing project dependencies

Your new project requires five main dependencies:

  • mobx is a Dart port of MobX for writing state-modifying logic.
  • flutter_mobx is Flutter integration for MobX that provides the Observer widget that automatically rebuilds based on changes to observable state.
  • shared_preferences is a local persistence library.
  • mobx_codegen is a code generation library for MobX that allows usage of MobX annotations.
  • build_runner is a stand-alone library to run code generation operations.

With the project open in your IDE, navigate to your /pubspec.yaml file to add the dependencies. Replace the dependencies section with this snippet:

dependencies:
  flutter:
    sdk: flutter
  mobx: ^0.3.5
  flutter_mobx: ^0.3.0+1
  shared_preferences: ^0.5.3+4

Then replace the dev_dependencies section with this snippet of code:

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.6.5
  mobx_codegen: ^0.3.3+1

Now, run this command in your project’s root directory to download the dependencies:

$ flutter packages get

What you are building

In this tutorial, you are building a simple review application that allows users to add comments and stars as seen in the images below:

How to structure the sample Flutter project

The sample project described in the What you’ll build section works as follows:

  • Start the app
  • Get reviews from local preferences
  • Update UI with retrieved reviews
  • Add review
  • Update list of reviews within app state
  • Persist updated list of reviews in preferences

Before getting started, create the /widgets, /screens, and /models folder by running this command in your project’s /lib directory:

$ mkdir widgets screens models

Creating the data model

To start with, define a data model for the reviews by creating a reviewmodel.dart file in the /lib/models/ directory. Add this code snippet to it:

import 'package:meta/meta.dart';
class ReviewModel {
  final String comment;
  final int stars;
  const ReviewModel({@required this.comment, @required this.stars});

  factory ReviewModel.fromJson(Map<String, dynamic> parsedJson) {
    return ReviewModel(
      comment: parsedJson['comment'],
      stars: parsedJson['stars'],
    );
  }

  Map<String, dynamic> toJson(){
    return {
      'comment': this.comment,
      'stars': this.stars,
    };
  }
}

Creating the user interface

The sample application we are building needs a way for users to interact with it. The app will contain a review form that displays a list of existing reviews, the total number of reviews, and the average number of stars for each review. The form will also let users add a new review.

Start by creating a review.dart file in the /lib/screens directory. Add this code snippet:

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../widgets/info_card.dart';

class Review extends StatefulWidget {
  @override
  ReviewState createState() {
    return new ReviewState();
  }
}
class ReviewState extends State<Review> {
  final List<int> _stars = [1, 2, 3, 4, 5];
  final TextEditingController _commentController = TextEditingController();
  int _selectedStar;

  @override
  void initState() {
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Scaffold(
      appBar: AppBar(
        title: Text('Review App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            SizedBox(height: 12.0),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: <Widget>[
                Container(
                  width: screenWidth * 0.6,
                  child: TextField(
                    controller: _commentController,
                    decoration: InputDecoration(
                      contentPadding: EdgeInsets.all(10),
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10.0),
                      ),
                      hintText: "Write a review",
                      labelText: "Write a review",
                    ),
                  ),
                ),
                Container(
                  child: DropdownButton(
                    hint: Text("Stars"),
                    elevation: 0,
                    value: _selectedStar,
                    items: _stars.map((star) {
                      return DropdownMenuItem<int>(
                        child: Text(star.toString()),
                        value: star,
                      );
                    }).toList(),
                    onChanged: (item) {
                      setState(() {
                        _selectedStar = item;
                      });
                    },
                  ),
                ),
                Container(
                  child: Builder(
                    builder: (BuildContext context) {
                      return IconButton(
                        icon: Icon(Icons.done),
                        onPressed: () {},
                      );
                    },
                  ),
                ),
              ],
            ),
            SizedBox(height: 12.0),
            //contains average stars and total reviews card
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: <Widget>[
                InfoCard(
                    infoValue: '2',
                    infoLabel: "reviews",
                    cardColor: Colors.green,
                    iconData: Icons.comment),
                InfoCard(
                  infoValue: '2',
                  infoLabel: "average stars",
                  cardColor: Colors.lightBlue,
                  iconData: Icons.star,
                  key: Key('avgStar'),
                ),
              ],
            ),
            SizedBox(height: 24.0),
            //the review menu label
            Container(
              color: Colors.grey[200],
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Icon(Icons.comment),
                  SizedBox(width: 10.0),
                  Text(
                    "Reviews",
                    style: TextStyle(fontSize: 18),
                  ),
                ],
              ),
            ),
            //contains list of reviews
            Expanded(
              child: Container(
                child: Text("No reviews yet"),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Creating custom widgets

In this code snippet there is a reference to InfoCard. InfoCard is a custom widget that displays the total number of reviews and the average number of stars:

Infocard custom widget for review app

To create the InfoCard widget, create a file named info_card.dart in the /lib/widgets directory. Add this code snippet:

import 'package:flutter/material.dart';

class InfoCard extends StatelessWidget {
  final String infoValue;
  final String infoLabel;
  final Color cardColor;
  final IconData iconData;
  const InfoCard(
      {Key key,
      @required this.infoValue,
      @required this.infoLabel,
      @required this.cardColor,
      @required this.iconData,
      })
      : super(key: key);
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Container(
      height: 100,
      width: screenWidth / 2,
      child: Card(
        color: cardColor,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(5.0),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: <Widget>[
            Icon(
              iconData,
              size: 28.0,
              color: Colors.white,
            ),
            Text(
              "$infoValue $infoLabel",
              style: TextStyle(color: Colors.white),
            ),
          ],
        ),
      ),
    );
  }
}

Although you will not need it until later in the tutorial, create a ReviewWidget class. This class will be used to display a single review item. Start by creating a review.dart file in the project’s lib/widgets directory. Add this code snippet:

import 'package:flutter/material.dart';
import '../models/reviewmodel.dart';
import '../models/reviews.dart';
import '../widgets/review.dart';
import '../widgets/info_card.dart';

class ReviewWidget extends StatelessWidget {
  final ReviewModel reviewItem;

  const ReviewWidget({Key key, @required this.reviewItem}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Padding(
          padding: EdgeInsets.all(10.0),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: <Widget>[
              Expanded(
                child: Text(
                  reviewItem.comment,
                ),
              ),
              Row(
                children: List(reviewItem.stars).map((listItem) {
                  return Icon(Icons.star);
                }).toList(),
              ),
            ],
          ),
        ),
        Divider(
          color: Colors.grey,
        )
      ],
    );
  }
}

Implementing MobX

To implement MobX in your application you need to define the observables, actions, and computed values in the application state.

At any point in time in the application, the list of reviews, average number of stars, and total number of reviews must be the most-recent value available. This means that they must be declared with annotations so that changes to them can be tracked by MobX.

To do this, create a file reviews.dart in the /lib/models directory of your project. Add this code snippet:

import 'dart:async';
import 'dart:convert';
import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import './reviewmodel.dart';
part 'reviews.g.dart';
class Reviews = ReviewsBase with _$Reviews;
abstract class ReviewsBase with Store {
  @observable
  ObservableList<ReviewModel> reviews = ObservableList.of([]);

  @observable
  double averageStars = 0;

  @computed
  int get numberOfReviews => reviews.length;

  int totalStars = 0;

  @action
  void addReview(ReviewModel newReview) {
    //to update list of reviews
    reviews.add(newReview);
    // to update the average number of stars
    averageStars = _calculateAverageStars(newReview.stars);
    // to update the total number of stars
    totalStars += newReview.stars;
    // to store the reviews using Shared Preferences
    _persistReview(reviews);
  }

  @action
  Future<void> initReviews() async {
    await _getReviews().then((onValue) {
      reviews = ObservableList.of(onValue);
      for (ReviewModel review in reviews) {
        totalStars += review.stars;
      }
    });
    averageStars = totalStars / reviews.length;
  }

  double _calculateAverageStars(int newStars) {
    return (newStars + totalStars) / numberOfReviews;
  }

  void _persistReview(List<ReviewModel> updatedReviews) async {
    List<String> reviewsStringList = [];
    SharedPreferences _preferences = await SharedPreferences.getInstance();
    for (ReviewModel review in updatedReviews) {
      Map<String, dynamic> reviewMap = review.toJson();
      String reviewString = jsonEncode(ReviewModel.fromJson(reviewMap));
      reviewsStringList.add(reviewString);
    }
    _preferences.setStringList('userReviews', reviewsStringList);
  }
  
  Future<List<ReviewModel>> _getReviews() async {
    final SharedPreferences _preferences =
        await SharedPreferences.getInstance();
    final List<String> reviewsStringList =
        _preferences.getStringList('userReviews') ?? [];
    final List<ReviewModel> retrievedReviews = [];
    for (String reviewString in reviewsStringList) {
      Map<String, dynamic> reviewMap = jsonDecode(reviewString);
      ReviewModel review = ReviewModel.fromJson(reviewMap);
      retrievedReviews.add(review);
    }
    return retrievedReviews;
  }
}

This code declares two variables:

  1. reviews is a list of all user reviews
  2. averageStars is the average number of stars computed as observables from all reviews. They are computed as observables because their values are expected to change in response to an action. The code then defines the addReview() function, which adds a new review to the list of reviews. It also adds an initReviews() function to initialize the list of reviews with existing data from shared preferences as actions that update the observable states.

Although the numberOfReviews variable can also be declared as an observable, a computed value is used instead, because changes to its value are dependent on the result of an action (updated observable state) rather than directly on the action itself. Think of it as an aftermath effect. Finally, a totalStars variable and the functions _calculateAverageStars(), _persistReview(), and _getReviews() are declared. These have no annotations because they are helper parameters that do not directly update state.

Running CodeGen

As a result of MobX’s focus on abstracting high-level implementation details, the library handles the process of generating a data store. In contrast, Redux requires that even stores are manually written. MobX performs code generation by using its mobx_codegen library with Dart’s build_runner library, and takes all annotated properties into consideration when scaffolding a store.

Go to your project’s root directory, and run the command:

$ flutter packages pub run build_runner build

After you generate the store, you will find a review.g.dart file in your /lib/models directory.

Using observers

Even with a MobX store implemented, reflecting state changes in your application’s UI requires the use of Observers from the flutter_mobx library. An observer is a widget that wraps around an observable or a computed value to render changes in their values to the UI.

The values of average stars, number of reviews, and total number of reviews get updated as each new review is added. This implies that the widgets used in rendering the values are being wrapped in an Observer widget. To use the observer widget, navigate to your /lib/screens/review.dart file. Modify the ReviewState class by using this code:

class ReviewState extends State<Review> {
  final Reviews _reviewsStore = Reviews();
  final TextEditingController _commentController = TextEditingController();  
  final List<int> _stars = [1, 2, 3, 4, 5];
  int _selectedStar;
  @override
  void initState() {
    _selectedStar = null;
    _reviewsStore.initReviews();
    super.initState();
  }
  @override
  Widget build(BuildContext context) {
    Size screenSize = MediaQuery.of(context).size;
    double screenWidth = screenSize.width;
    return Scaffold(
      appBar: AppBar(
        title: Text('Review App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            SizedBox(height: 12.0),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: <Widget>[
                Container(
                  width: screenWidth * 0.6,
                  child: TextField(
                    controller: _commentController,
                    decoration: InputDecoration(
                      contentPadding: EdgeInsets.all(10),
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10.0),
                      ),
                      hintText: "Write a review",
                      labelText: "Write a review",
                    ),
                  ),
                ),
                Container(
                  child: DropdownButton(
                    hint: Text("Stars"),
                    elevation: 0,
                    value: _selectedStar,
                    items: _stars.map((star) {
                      return DropdownMenuItem<int>(
                        child: Text(star.toString()),
                        value: star,
                      );
                    }).toList(),
                    onChanged: (item) {
                      setState(() {
                        _selectedStar = item;
                      });
                    },
                  ),
                ),
                Container(
                  child: Builder(
                    builder: (BuildContext context) {
                      return IconButton(
                        icon: Icon(Icons.done),
                        onPressed: () {
                          if (_selectedStar == null) {
                            Scaffold.of(context).showSnackBar(SnackBar(
                              content:
                                  Text("You can't add a review without star"),
                              duration: Duration(milliseconds: 500),
                            ));
                          } else if (_commentController.text.isEmpty) {
                            Scaffold.of(context).showSnackBar(SnackBar(
                              content: Text("Review comment cannot be empty"),
                              duration: Duration(milliseconds: 500),
                            ));
                          } else {
                            _reviewsStore.addReview(ReviewModel(
                                comment: _commentController.text,
                                stars: _selectedStar));
                          }
                        },
                      );
                    },
                  ),
                ),
              ],
            ),
            SizedBox(height: 12.0),
            //contains average stars and total reviews card
            Observer(
              builder: (_) {
                return Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: <Widget>[
                    InfoCard(
                      infoValue: _reviewsStore.numberOfReviews.toString(),
                      infoLabel: "reviews",
                      cardColor: Colors.green,
                      iconData: Icons.comment
                    ),
                    InfoCard(
                      infoValue: _reviewsStore.averageStars.toStringAsFixed(2),
                      infoLabel: "average stars",
                      cardColor: Colors.lightBlue,
                      iconData: Icons.star,
                      key: Key('avgStar'),
                    ),
                  ],
                );
              },
            ),
            SizedBox(height: 24.0),
            //the review menu label
            Container(
              color: Colors.grey[200],
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Icon(Icons.comment),
                  SizedBox(width: 10.0),
                  Text(
                    "Reviews",
                    style: TextStyle(fontSize: 18),
                  ),
                ],
              ),
            ),
            //contains list of reviews
            Expanded(
              child: Container(
                child: Observer(
                  builder: (_) => _reviewsStore.reviews.isNotEmpty
                      ? ListView(
                          children:
                              _reviewsStore.reviews.reversed.map((reviewItem) {
                            return ReviewWidget(
                              reviewItem: reviewItem,
                            );
                          }).toList(),
                        )
                      : Text("No reviews yet"),
                ),
              ),
            )
          ],
        ),
      ),
    );
  }
}

This code appends the first modification by creating an instance of the Review class from /lib/models/reviews.dart as a means of accessing the store. It then wraps the Row that the average stars and total reviews data are displayed in with an observer widget. Then it uses reviewStore instance of the Review class to refer to the data.

Next, the placeholder “no reviews” Text widget is made to display when the list of reviews in the store is empty. Otherwise a ListView displays the items of the list. Finally, the onPressed() function of the “Done” button is modified to add a new review to the store.

At this point, your application is almost complete. Your next step is to import the review screen to the imports section of your main.dart file. Open the file, and add this snippet:

$ import './screens/review.dart';

In /lib/main.dart, modify the home attribute in the build() method of the MyApp class. Change the home attribute from MyHomePage() to Review(). Here is the code:

@override
Widget build(BuildContext context) {
  return MaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
    home: Review() //previously MyHomePage(),
  );
}

Finally, run the app using the flutter run command.

Writing sample tests

To understand how tests fit into a CI/CD pipeline, you will need to create a simple unit test and widget test.

To write the unit test, create a file called unit_test.dart in the project’s /test directory. Add this code snippet:

import 'package:flutter_test/flutter_test.dart';
import '../lib/models/reviewmodel.dart';
import '../lib/models/reviews.dart';

void main() {
  test('Test MobX state class', () async {
    final Reviews _reviewsStore = Reviews();

    _reviewsStore.initReviews();

    expect(_reviewsStore.totalStars, 0);

    expect(_reviewsStore.averageStars, 0);
    _reviewsStore.addReview(ReviewModel(
      comment: 'This is a test review',
      stars: 3,
    ));

    expect(_reviewsStore.totalStars, 3);
    _reviewsStore.addReview(ReviewModel(
      comment: 'This is a second test review',
      stars: 5,
    ));

    expect(_reviewsStore.averageStars, 4);
  });
}

Next, add the widget test by totally replacing the contents of the existing widget_test.dart file in your project’s test directory with this code snippet:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';

void main() {
  testWidgets('Test for rendered UI', (WidgetTester tester) async {
    await tester.pumpWidget(MyApp());
    Finder starCardFinder = find.byKey(Key('avgStar'));

    expect(starCardFinder, findsOneWidget);
  });
}

Run the tests by executing the flutter test command in your project’s root directory.

CI/CD with CircleCI

Beyond just providing an environment for you to build your projects, continuous integration (CI) tools provide a reliable and stable environment to run automated tests and auto-upload deploy artifacts. In this section, you will learn how to setup and utilize a CI/CD pipeline for your Flutter project using CircleCI.

To start, initialize a local Git repository in your project by executing the git init command in your project’s root directory. Add your files to it with git add .. Commit those files with git commit -m "First commit". Then, create an online repository for your project on GitHub. Add the GitHub repository as a remote reference to your local repository, then push changes to the remote repository by running the command below in your project’s root directory:

$ git remote add origin https://link_to_repo && git push -u origin master

Creating a configuration file

Create a folder named .circleci by running the mkdir .circleci command in your project’s root directory. Create a configuration file so that the file path is structured like this /your_project_path/.circleci/config.yml.

Then, populate the /.circleci/config.yml file with this code snippet:

version: 2
jobs:
  build:    
    docker:
      - image: cirrusci/flutter:v1.5.8 

    branches:
      only: master

    steps:
      - checkout

      - run:
          name: Run Flutter doctor
          command: flutter doctor

      - run:
          name: Run the application tests
          command: flutter test

      - run:
          name: Build the Android version
          command: flutter build apk 

      - store_artifacts:
          path: build/app/outputs/apk/release/app-release.apk

In this configuration file, Docker was used as the executor. There is no official CircleCI Docker image for Flutter, but there is a large list of Flutter images available on DockerHub. The most prominent is the cirrusci/flutter image. This image has a usage frequency of over 1M pulls.

The optional branches section of the config file is used to filter the branches that the deployment process runs on. When not explicitly defined, CircleCI assumes master as the branch to work on.

The config defines which Docker image to use. It also pins a version of the image that matches the version of Flutter running the local copy of your project, (v1.5.8 in my case).

In the steps section, the config file defines each process that is to be performed every time the deployment is run on your project’s repository, in their execution order.

Lastly, in the store-artifacts section of the code snippet above, a path to our build artifact is referenced. This enables automatic upload of the artifact to the Artifacts tab of your CircleCI dashboard. The artifact could be deployed to an AWS S3 bucket or any other hosting service. For production-ready Flutter applications, you could add deployment to an app store to this config.

Setting up CircleCI

To integrate CircleCI with your project, go to the CircleCI dashboard, and click Add Projects. It is on the far left of the dashboard’s page. Next, navigate to the far right of the page and click Set Up Project.

Add Projects Screen

On the next page, click Start Building.

Project setup interface

Your build will begin.

Building project

Now, every commit of new code will trigger an automated build, test, and deployment pipeline for your Flutter application.

Conclusion

In this post, we covered how to manage the state of a Flutter application with the MobX state management library. We also covered how to setup a CI/CD pipeline for your application using CircleCI.

Although there are trade-offs to consider in deciding on a choice of state management approaches for a Flutter project, especially large ones, MobX offers a solid option for medium-sized projects. The library does the hardest work for you, while giving you the power to take charge where necessary. That is big benefit to consider when you decide on state management for your next Flutter project, and maybe even rewriting a current one!

Development teams continue to adopt MobX for their Flutter projects. Check out the library’s GitHub repository for ways to contribute to its growth.

I do hope that you enjoyed this tutorial. Have fun coding!


Fabusuyi is a Software Engineering Intern at Busha where he works with the mobile development team. He’s currently a Computer Science student with a research interest in Computational Theory.