Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app

When looking at building applications for Flutter, state management has become a hot topic that there's now a dedicated section on it on the official Flutter website. Having come from a background where...

Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app

Note: at the time of writing this article, the ProxyProvider class wasn't available in the provider package. I may look at updating the app to make use of it at a later stage. Readers should also check https://mobx.netlify.app/guides/stores as I've contributed to the official documentation on how to use ProxyProvider. This includes a change to how I now recommend structuring applications whereby dependencies are passed directly to via constructor. Besides that, the rest of the article should still be of use to developers looking to use MobX. I have updated the code to use the latest version of provider etc

When looking at building applications for Flutter, state management has become a hot topic that there's now a dedicated section on it on the official Flutter website. Having come from a background where I've worked on building mobile and desktop .NET applications, MVVM has become the standard architectural pattern. The UI would written in XAML and with the ability to wire up the UI with a view model through data binding. Whilst researching for ways that could help manage updating the UI within Flutter apps that were similar how I would normally do so in the past, two approaches that stood out to me were the BLoC pattern and MobX.

There are quite a few resources that have gone through the BLoC pattern so I won't dwelve into it much detail. To summarise though, it requires using the StreamBuilder widget that is wired up to listen to a stream (which that may reside in, say, a view model. The stream, which may be implemented using RxDart, will contain information on if the state has changed (e.g. app has progressed from loading data to finish loading) and the StreamBuilder widget will rebuild the UI in response. Some developers may consider working with streams a complex topic and that setting up the BLoC pattern involves too much effort although there are packages that can help get around that.

MobX provides similar capabilities that doesn't require dealing with streams. Code generation can also make it easier for developers to work by reducing the amount of code developers need to write to wire everything up. It makes use of three core concepts as described here

  1. Observables - represents part of your application's state
  2. Actions - responsible for mutating state
  3. Reactions - responsible for reacting to changes in state

To illustrate, lets dissect an application I've written called SUpNews. This is a basic application that connects to the Hacker News API to display stories and is hosted on GitHub here.

Overview and architecture

Breaking down the app, it essentially consists of four pages

  1. New stories page - for displaying new stories
  2. Top stories page - for displaying top stories
  3. Favourites page - for keep track of favourite stories
  4. Settings page - for toggling on and off dark mode and how stories should be opened
Screenshot of the new stories page

Tapping on a story will be default, open it "in-app". There is also the option it on the device's browser, ability to share a story, or have it added to the user's list of favourites. If the story exists in their list of favourites already then the user will be presented with the option of removing the story from their favourites. Scrolling the list of stories will also cause more stories to be loaded

Options available for a story
Settings page

If you read the documentation on the website on MobX for Dart here, it talks about having a widget-store-service hierarchy, where the widget represents the state you want to depict in your UI, a store contains information about the state and a service is used to perform work like fetching data from an API that would then be kept in your store. If we apply this to SUpNews, the architecture looks somewhat like the following

Architecture of the application. The arrows represent the flow of direct interaction between the various components

This looks more complicated than it actually is but each page will be associated with a store that will in turn make use of one or more services. The available services are

  • Hacker News API client - this will retrieve the stories need to be displayed. Makes use of the hnpwa_client package, though I'm actually using a fork due to an issue I've found
  • Sharing service - provides a way to share stories. This makes use of the share package
  • Story service - responsible for opening stories for users to read in-app or in a separate browser. This is done using the launcher package
  • Shared preferences service - used to help manage the users preferences within the app via the shared_preferences plugin

The sharing service, story service and shared preferences service may be a bit overkill but helps provide an abstraction in case the implementation of the methods need to differ on the various platforms. For example, whilst this app only supports Android and iOS right now, on the web you could story service navigate the user to the story via the browser that they'd already be using to run the app.

The news stories and top stories page also communicate with the favourites store since the user can manage their list of favourite stories through these pages. In general, each page will be associated with a store and the stores themselves don't have a reference to the page. This should be familiar to those have experience with doing MVVM applications. In Xamarin.Forms, your pages are likely be done in XAML as mentioned before and wired up with the associated view model. An IoC container is used to manage dependencies and your view models would get the dependencies (i.e. the services) it needs through constructor injection. A similar approach can be taken here with Flutter as we shall soon see.

Organising stores and services

Now that we've seen all the components within the application, we need to look at how we can manage the stores and services. The provider package is a popular choice that for making using of dependency injection with widgets. In fact, the Flutter team has recently spoken on a talk on Pragmatic State Management in Flutter. The MobX for Dart documentation also suggests using provider. Note that with Flutter, there is a notion of "lifting state up" whereby state is kept above the widgets. If there are two widgets need to make use of the same state, then the state should be kept above the nearest ancestor to both widgets. This helps avoid (1) unnecessary rebuilding of the UI and (2) widening the scope that state more than necessary.

Looking at the main.dart file that contains the entry point of the app, we see the following

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_statusbarcolor/flutter_statusbarcolor.dart';
import 'services/preferences_service.dart';
import 'widgets/app.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  var sharedPreferences = await SharedPreferences.getInstance();
  await FlutterStatusbarcolor.setStatusBarColor(Colors.teal);
  if (useWhiteForeground(Colors.teal)) {
    await FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
  } else {
    await FlutterStatusbarcolor.setStatusBarWhiteForeground(false);
  }
  runApp(App(PreferencesService(sharedPreferences)));
}

Here we try to get an instance of the SharedPreferences before running the app as it's in asynchronous operation that only needs to be done once and makes changing the values of the preferences much easier later on since those involve synchronous operations.

The app class looks like

import 'package:flutter/material.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:provider/provider.dart';
import '../services/sharing_service.dart';
import '../services/story_service.dart';
import '../stores/favourites_store.dart';
import '../stores/settings_store.dart';
import '../services/preferences_service.dart';
import 'themeable_app.dart';

class App extends StatelessWidget {
  final PreferencesService _preferencesService;

  const App(this._preferencesService);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<PreferencesService>(
          create: (_) => _preferencesService,
        ),
        Provider<HnpwaClient>(
          create: (_) => HnpwaClient(),
        ),
        Provider<SettingsStore>(
          create: (_) => SettingsStore(_preferencesService),
        ),
        Provider<FavouritesStore>(
          create: (_) => FavouritesStore(_preferencesService),
        ),
        Provider<SharingService>(
          create: (_) => SharingService(),
        ),
        Provider<StoryService>(
          create: (_) => StoryService(_preferencesService),
        )
      ],
      child: Consumer<SettingsStore>(
        builder: (context, value, _) => ThemeableApp(value),
      ),
    );
  }
}

The aptly named Provider widget helps provides descendents an instance of a specified class when needed. The Provider's builder method is invoked once to instantiate and return an instance of a type that has been by requested by a descendent widget. These descendent widgets would be attached to child property of the Provider widget. We've leveraged the MultiProvider widget us to define a collection of Providers so we that don't have to deal with nesting Provider widgets (see the package's readme for more details) . Here we've defined the dependencies that are common amongst the pages. The SettingsStore is a notable exception to this as it contains the state on if the user has toggled dark mode on or off. This is done by consuming an instance of the SettingsStore so that it can be passed to the ThemeableApp widget via the Consumer widget. This enables the app to change the theme when the user toggles on or off the dark mode setting as seen below.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:provider/provider.dart';
import '../screens/favourites_page.dart';
import '../stores/favourites_store.dart';
import '../screens/new_stories_page.dart';
import '../screens/settings_page.dart';
import '../screens/top_stories_page.dart';
import '../services/preferences_service.dart';
import '../stores/new_stories_store.dart';
import '../stores/top_stories_store.dart';
import '../stores/settings_store.dart';

class ThemeableApp extends StatelessWidget {
  final SettingsStore settingsStore;
  ThemeableApp(this.settingsStore, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Observer(
      builder: (_) {
        var themeData = ThemeData(
          fontFamily: 'Lato',
          brightness: settingsStore.useDarkMode == true
              ? Brightness.dark
              : Brightness.light,
          primarySwatch: Colors.teal,
          textTheme: TextTheme(
            subtitle1: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
            subtitle2: TextStyle(fontWeight: FontWeight.w300),
          ),
        );
        return MaterialApp(
          title: 'SUpNews',
          theme: themeData,
          home: SafeArea(
            child: CupertinoTabScaffold(
              tabBar: CupertinoTabBar(
                items: [
                  BottomNavigationBarItem(
                    icon: Icon(Icons.new_releases),
                    title: Text('New'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.trending_up),
                    title: Text('Top'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.favorite),
                    title: Text('Favourites'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.settings),
                    title: Text('Settings'),
                  ),
                ],
              ),
              tabBuilder: (BuildContext context, int index) {
                switch (index) {
                  case 0:
                    return Consumer2<HnpwaClient, PreferencesService>(
                      builder: (context, hnpwaClient, preferencesService, _) =>
                          Provider(
                        create: (_) =>
                            NewStoriesStore(hnpwaClient, preferencesService),
                        child: Consumer<NewStoriesStore>(
                          builder: (context, value, _) => Material(
                            child: NewStoriesPage(
                              value,
                            ),
                          ),
                        ),
                      ),
                    );
                  case 1:
                    return Consumer2<HnpwaClient, PreferencesService>(
                      builder: (context, hnpwaClient, preferencesService, _) =>
                          Provider(
                        create: (_) => TopStoriesStore(
                          hnpwaClient,
                          preferencesService,
                        ),
                        child: Consumer<TopStoriesStore>(
                          builder: (context, value, _) => Material(
                            child: TopStoriesPage(
                              value,
                            ),
                          ),
                        ),
                      ),
                    );
                  case 2:
                    return Consumer<FavouritesStore>(
                      builder: (context, value, _) => Material(
                        child: FavouritesPage(value),
                      ),
                    );
                  case 3:
                    return Consumer<SettingsStore>(
                      builder: (context, value, _) => Material(
                        child: SettingsPage(value),
                      ),
                    );
                }
                return null;
              },
            ),
          ),
        );
      },
    );
  }
}
The ThemeableApp class where each tab is defined

Within the ThemeableApp class, we can see that we have defined the pages within the app are displayed within four tabs. Looking at the new stories page as an example (within the case 0 block), we make use of Consumer2 widget as it allows us to get instances of two different class types. More specifically, we're trying to get hold of the HnpwaClient (the Hacker News API client) and an instance of the PreferencesService. We consume these services so that we can provide an instance of the NewStoriesStore that is dependent on them, which in turn would get consumed by the NewStoriesPage. We can see that the code follows what was just described through the nesting of Consumer and Provider widgets.

Connecting stores to widgets to update the UI

Now that we have stores passed to our pages, let's see how they are wired up together so that the UI responds to changes in state. Once again, we'll be using the new stories page as an example. When the new stories page appears, a call to the Hacker News API should be done to fetch the newest stories. Whilst that is happening, we can indicate to to the user that the app is busy fetching data. Once the results have come back from the API, the new stories page should display the list of stories.

Our new stories page is defined as followed

import 'package:flutter/foundation.dart';
import '../screens/stories_page.dart';
import '../stores/new_stories_store.dart';

class NewStoriesPage extends StoriesPage<NewStoriesStore> {
  NewStoriesPage(NewStoriesStore store, {Key key}) : super(store, key: key);
}
The new stories page class

It extends a StoriesPage class as both the new stories page and top stories page have identical UI but have difference sources of data.

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:incrementally_loading_listview/incrementally_loading_listview.dart';
import '../stores/stories_store.dart';
import '../widgets/placeholder_stories.dart';
import '../widgets/placeholder_story.dart';
import '../widgets/story.dart';

class StoriesPage<T extends StoriesStore> extends StatefulWidget {
  final T store;
  StoriesPage(this.store, {Key key}) : super(key: key);

  @override
  _StoriesPageState createState() => _StoriesPageState();
}

/// Notes: use of [AutomaticKeepAliveClientMixin] with the [wantKeepAlive] override will effectively allow Flutter to retain the page state, including the scroll position.
/// Without it, switching back and forth between tabs would cause the data to tab to be rebuilt, which in turn causes data to be fetched etc
class _StoriesPageState<T extends StoriesStore> extends State<StoriesPage>
    with AutomaticKeepAliveClientMixin {
  @override
  void initState() {
    super.initState();
    widget.store.loadInitialStories();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Observer(
      builder: (_) {
        switch (widget.store.loadFeedItemsFuture.status) {
          case FutureStatus.rejected:
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Oops something went wrong'),
                  RaisedButton(
                    child: Text('Retry'),
                    onPressed: () async {
                      await widget.store.retry();
                    },
                  ),
                ],
              ),
            );
          case FutureStatus.fulfilled:
            return RefreshIndicator(
              child: IncrementallyLoadingListView(
                loadMore: () async {
                  await widget.store.loadNextPage();
                },
                hasMore: () => widget.store.hasNextPage,
                itemCount: () => widget.store.feedItems.length,
                itemBuilder: (context, index) {
                  if (index == widget.store.feedItems.length - 1 &&
                      widget.store.hasNextPage &&
                      !widget.store.loadingNextPage) {
                    return Column(
                      children: [
                        Story(widget.store.feedItems[index]),
                        PlaceholderStory(),
                      ],
                    );
                  }
                  return Story(widget.store.feedItems[index]);
                },
              ),
              onRefresh: () async {
                await widget.store.refresh();
              },
            );
          case FutureStatus.pending:
          default:
            return PlaceholderStories();
        }
      },
    );
  }

  @override
  bool get wantKeepAlive => true;
}
The generic stories page class

The Observer widget is provided by the flutter_mobx package and is part of the reacts to changes in state within the NewStoriesStore. So let's see what's defined within the store

import 'package:hnpwa_client/hnpwa_client.dart';
import '../common/enums.dart';
import '../services/preferences_service.dart';
import 'stories_store.dart';

class NewStoriesStore extends StoriesStore {
  NewStoriesStore(
      HnpwaClient hnpwaClient, PreferencesService preferencesService)
      : super(StoryFeedType.New, hnpwaClient, preferencesService);
}
The NewStoriesStore class

The store extends StoriesStore class much like how the NewStoriesPage extends StoriesPage

import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:mobx/mobx.dart';
import 'package:url_launcher/url_launcher.dart';
import '../common/enums.dart';
import '../services/preferences_service.dart';

part 'stories_store.g.dart';

class StoriesStore = StoriesStoreBase with _$StoriesStore;

abstract class StoriesStoreBase with Store {
  final StoryFeedType _storyFeedType;
  final HnpwaClient _hnpwaClient;
  final PreferencesService _preferencesService;

  int _currentPage = 1;
  bool _isLoadingNextPage = false;

  @observable
  bool hasNextPage = false;

  @observable
  ObservableList<FeedItem> feedItems = ObservableList<FeedItem>();

  @observable
  ObservableFuture loadFeedItemsFuture;

  @observable
  bool loadingNextPage = false;

  StoriesStoreBase(
      this._storyFeedType, this._hnpwaClient, this._preferencesService);

  @action
  Future<void> refresh() {
    return _loadFirstPageStories();
  }

  @action
  Future<void> retry() {
    return loadFeedItemsFuture = ObservableFuture(_loadFirstPageStories());
  }

  @action
  Future<void> loadInitialStories() {
    return loadFeedItemsFuture = ObservableFuture(_loadFirstPageStories());
  }

  Future<void> open(String url) {
    final defaultOpenInAppPreference = _preferencesService.openInApp;
    return launch(url,
        forceSafariVC: defaultOpenInAppPreference,
        forceWebView: defaultOpenInAppPreference);
  }

  @action
  Future<void> loadNextPage() async {
    try {
      if (_isLoadingNextPage || (_currentPage > 1 && !hasNextPage)) {
        return;
      }
      _isLoadingNextPage = true;
      var feed = _storyFeedType == StoryFeedType.Top
          ? (await _hnpwaClient.news(page: _currentPage))
          : (await _hnpwaClient.newest(page: _currentPage));
      // some items from the official API don't have a URL but the HNPWA API will put "item?={id}" as the URL so need to filter those out
      feedItems.addAll(feed.items.where((fi) {
        var uri = Uri.tryParse(fi.url);
        return uri != null && uri.hasScheme;
      }));
      hasNextPage = feed.hasNextPage;
      _currentPage++;
    } finally {
      _isLoadingNextPage = false;
    }
  }

  @action
  Future<void> _loadFirstPageStories() async {
    feedItems.clear();
    _currentPage = 1;
    await loadNextPage();
  }
}

Here a number of properties have the @observable annotation attached. The annotation is used to indicate the state that the UI will react to when the value changes

  • hasNextPage - indicates if the Hacker News API has another set of stories that the app can fetch
  • feedItems - this is the actual list of stories that can be displayed. This is an instance of the ObservableList<T> class defined within MobX. It is similar to the ObservableCollection defined in .NET where collection-based UI elements (e.g. a ListView) would bind to an ObservableCollection. If the collection gets modified then the UI be updated to reflect that
  • loadFeedItemsFuture - this is used to represent the asynchronous work for fetching the initial stories to display. The work being performed may be different as we may be retrying or loading the initial set of stories upon landing on the page. The property as defined as instance of the ObservableFuture type defined within MobX. It is similar to the Future class already defined within the Flutter framework but has extra functionality when using MobX with Flutter.
  • loadingNextPage - this is used to indicate if the app is fetching the next set of stories

Methods that can change state decorated with the @observable annotation will have an @action annotation attached to them

  • refresh - used when the the user has requested to refresh the stories displayed
  • retry - used when an error has occurred whilst fetching stories (e.g. app is offline when the app starts) and the user has requested to retry fetching the stories
  • loadInitialStories - used to load the initial set of stories upon loading the page
  • loadNextPage - used fetch the next set of stories when the user scrolls to the bottom of the page. The page will check the value of hasNextPage and loadingNextPage to determine if it can load the next set of stories
  • _loadFirstPageStories - used to fetch the first set/page of stories. This is a common method called by retry and loadInitialStories. Of note here is that even private methods that modify observables need to have the @action annotation as well.

All stories are fetched using the HnpwaClient class that is a service consumed by the store. Another thing to note is the is the line part 'stories_store.g.dart'; that is part of the store's code. This is because making use of annotations requires the MobX code generation to run, which will create a stories_store.g.dart file that contains more code on the underlying implementation of how the annotated code actually works without us having to worry about that ourselves. More details can be found in MobX's getting started guide.

So now that we see what's within the store, let's go through how the page and store are connected. Upon loading the new stories page, the initState method is invoked that will call the loadInitialStories method defined within the store. The build method defined is responsible for rendering the page and has the Observer widget as mentioned earlier. The widget is responsible for monitoring if the underlying observables used further in get changed and trigger the UI to be rebuilt when this occurs.  From the perspective the page, the follow reactions can occur

  • If the status of the loadFeedItemsFuture property is  FutureStatus.pending,  a loading indicator is displayed as stories are being fetched (status would be FutureStatus.pending)
  • If an error occurs whilst fetching stories (the status of loadFeedItemsFuture is FutureStatus.rejected), then an error message is displayed to notify the user about this and a button is presented then allows them to retry. Retrying would assign a new ObservableFuture value (this underlying value is the asynchronous work to retry) to the store's loadFeedItemFuture property that would cause the loading indicator to be displayed again.
  • If the operation to fetch stories has successfully completed (i.e. the status of loadFeedItemsFuture is FutureStatus.fulfilled), we present the list of stories to the user via the IncrementallyLoadingListView widget. This widget is a drop-in replacement for the standard ListView widget from a package I developed. When the user scrolls to the bottom, it can invoke the loadNextPage() method to retrieve the next set of stories from the API and trigger the list to be updated
  • The stories presented by the IncrementallyLoadingListView widget are based on the feedItems defined within the store. Each story is represented by the Story widget
  • Pull to refresh functionality is available that will call the refresh method defined in the store when triggered, which we've seen is a MobX action that can mutate state. The feedItems collection will be repopulated that will in turn update the list of stories displayed

If you've used the FutureBuilder widget then you'll notice that the how app makes use of the status of an ObservableFuture (the loadFeedItemsFuture property) to determine what should be rendered.

The favourites page (FavouritesPage) and its store (FavouritesStore) are connected in a similar way. However, the FavouritesStore is also used when the user interacts with a story via a long press gesture (i.e. tap and hold).

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:provider/provider.dart';
import '../services/story_service.dart';
import '../services/sharing_service.dart';
import '../stores/favourites_store.dart';

import 'styles.dart';

class Story extends StatelessWidget {
  final FeedItem _item;
  Story(this._item, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final storyService = Provider.of<StoryService>(context);
    final sharingService = Provider.of<SharingService>(context);
    final favouritesStore = Provider.of<FavouritesStore>(context);
    return InkWell(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
        child: Row(
          children: [
            Center(
              child: CircleAvatar(
                child: Center(
                  child: Text(
                    _item.points.toString(),
                  ),
                ),
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    TextSpacer(
                      Text(_item.title,
                          style: Theme.of(context).textTheme.subtitle1),
                    ),
                    TextSpacer(
                      Text(
                        _item.url,
                        overflow: TextOverflow.ellipsis,
                        style: Theme.of(context).textTheme.subtitle2,
                      ),
                    ),
                    TextSpacer(
                      Text(
                        '${_item.user} - ${DateFormat().format(
                          DateTime.fromMillisecondsSinceEpoch(
                              _item.time * 1000),
                        )}',
                        style: Theme.of(context).textTheme.subtitle2,
                      ),
                    ),
                    Text(
                      '${_item.commentsCount} ${_item.commentsCount == 1 ? 'comment' : 'comments'}',
                      style: Theme.of(context).textTheme.subtitle2,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
      onTap: () async {
        await storyService.open(_item.url);
      },
      onLongPress: () {
        showModalBottomSheet(
            context: context,
            builder: (BuildContext bc) {
              return Observer(
                builder: (_) => Container(
                  child: new Wrap(
                    children: <Widget>[
                      new ListTile(
                        leading: new Icon(Icons.favorite),
                        title: new Text(favouritesStore.isInFavourites(_item)
                            ? 'Remove from favourites'
                            : 'Add to favourites'),
                        onTap: () {
                          favouritesStore.isInFavourites(_item)
                              ? favouritesStore.removeFavourite(_item)
                              : favouritesStore.addFavourite(_item);
                          Navigator.of(context).pop();
                        },
                      ),
                      new ListTile(
                        leading: new Icon(Icons.open_in_browser),
                        title: new Text('Open in browser'),
                        onTap: () async {
                          await storyService.openInBrowser(_item.url);
                          Navigator.of(context).pop();
                        },
                      ),
                      if (_item.url != null)
                        new ListTile(
                          leading: new Icon(Icons.share),
                          title: new Text('Share'),
                          onTap: () async {
                            await sharingService.share(_item.url);
                            Navigator.of(context).pop();
                          },
                        ),
                    ],
                  ),
                ),
              );
            });
      },
    );
  }
}

Here we've made use of the Provider.of<T> method that the provider package has as an alternate approach compared to the Consumer when trying to resolve an instance of a class. By sharing the FavouritesStore with the Story widget, we can ensure the page associated with it will also be updated in response to the user managing their list of favourites outside of the FavouritesPage. Now if the user were to go to the favourites page to remove a story from their list of favourites that was added the new stories page, when they go back to the new stories page and tap and hold on the story, they will see that they get the option to add the story to their list of favourites again. We can see how MobX has made state management much easier as all of observers of the same state have been notified of the changes.

Conclusion

Hopefully this post has provided some useful information on how MobX can be used within a Flutter application in case you're interested in making use of it as well. Feel free to check the code in more detail (e.g. how the settings page is done as it hasn't been shown here) by checking out the repository on GitHub. It has been used by some notable companies for building React Native applications as seen here and can expected to work for Flutter applications.

For those that are familiar with building .NET mobile or desktop applications with the MVVM pattern (e.g. through Xamarin.Forms), a quick guide on how translate your experience is

  • To use stores as your view models
  • Properties within your view model/store that need to raise a property change notification so the changes are reflected in the UI should be decorated with the @observable annotation
  • Commands (which would implement the ICommand interface) that represent the actions the user can perform would be mapped to methods within your view model/store. If the method needs to update a property within your view model/store that would require the UI to be updated to reflect these changes then decorate the methods with the @action annotation
  • If you've been using an ObservableCollection in your view models to representation a collection of data that needs to be displayed, you can use an ObservableList with MobX. This tracks when items have been added, updated or removed from the collection as well
  • Connect your UI with your store so it can react to changes in state with the Observer widget

If you have any questions or comments, you can find me on Twitter. For more information about MobX for Dart, check the official site