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
- Observables - represents part of your application's state
- Actions - responsible for mutating state
- 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
- New stories page - for displaying new stories
- Top stories page - for displaying top stories
- Favourites page - for keep track of favourite stories
- Settings page - for toggling on and off dark mode and how stories should be opened
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
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
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 Provider
s 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.
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
It extends a StoriesPage
class as both the new stories page and top stories page have identical UI but have difference sources of data.
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
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 fetchfeedItems
- this is the actual list of stories that can be displayed. This is an instance of theObservableList<T>
class defined within MobX. It is similar to theObservableCollection
defined in .NET where collection-based UI elements (e.g. aListView
) would bind to anObservableCollection
. If the collection gets modified then the UI be updated to reflect thatloadFeedItemsFuture
- 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 theObservableFuture
type defined within MobX. It is similar to theFuture
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 displayedretry
- 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 storiesloadInitialStories
- used to load the initial set of stories upon loading the pageloadNextPage
- used fetch the next set of stories when the user scrolls to the bottom of the page. The page will check the value ofhasNextPage
andloadingNextPage
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 byretry
andloadInitialStories
. 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 theloadFeedItemsFuture
property isFutureStatus.pending
, a loading indicator is displayed as stories are being fetched (status would beFutureStatus.pending
) - If an error occurs whilst fetching stories (the
status
ofloadFeedItemsFuture
isFutureStatus.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 newObservableFuture
value (this underlying value is the asynchronous work to retry) to the store'sloadFeedItemFuture
property that would cause the loading indicator to be displayed again. - If the operation to fetch stories has successfully completed (i.e. the
status
ofloadFeedItemsFuture
isFutureStatus.fulfilled
), we present the list of stories to the user via theIncrementallyLoadingListView
widget. This widget is a drop-in replacement for the standardListView
widget from a package I developed. When the user scrolls to the bottom, it can invoke theloadNextPage()
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 thefeedItems
defined within the store. Each story is represented by theStory
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. ThefeedItems
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 anObservableList
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