Local Data Persistence in Flutter

Save my State

Richard Shepherd
Dec 11, 2019 · 11 min read

What?

In my previous article Stateless Flutter we looked at the classic Flutter starter app, but refactored it to Stateless Widgets only, and introduced the Provider package for state-management. In this article we will go a step further by adding persistent-storage to our app state data.

Recall that the Flutter starter app has a single screen with a zero-initialised counter in the center of the screen, and a “+” button in the lower-right that increments that counter, by one, in response to each tap. However every time we re-start the app, the counter begins at zero again. There is no persistence of our state data.

From a user-experience perspective, the sole change to the App should be that each time the App is started afresh, the counter value carries on where it left off from the previous run. The change to the code, however, will be more significant — which sets us up well for future improvements (to come in a later article in this series). Additionally — we will enhance the App to include a reset-to-zero button — just to make it ever-so-slightly more interesting.

Here’s a look at the finished product on an iOS simulator, including showing that we close and kill the running App, followed by opening it afresh with the previous value (3) freshly loaded from SharedPreferences:

The repo for the finished product above is at https://github.com/eggzotic/flutter_persistent_local. But don’t go there yet! The learning is in the journey we follow below, especially in the “But really, how?” section. If, at the end of that, your app is not quite working — then refer to the link above to “close the gap” so you to can see the above on your desktop.

Why?

Most real-world apps will need to save some data that is referenced again and again — every time the app is used. Mobile OS, such as iOS and Android, manage the lifecycle of each running App — when an App goes into the background it may be forced to quit any time before the user next opens it. Regardless of whether the App was quit in between times, the next time the user opens the app it should behave and function the same (apart from some session-based apps, e.g. mobile-banking) as where they left off. An app may still be running from the last time it was opened, or it may need to open-from-scratch and any “state” may have to be reloaded. The App-code (yes, the code that we as developers write) needs to take care of this without the users having to concern themselves with whether that data has been appropriately stored for later access.

How?

Flutter offers us several ways we can achieve persistent data storage for our mobile Apps, including:

Local, on-device storage, e.g.:

  • in a file

Off-device storage such as:

  • in remote object storage e.g. S3

In this article we will augment our Stateless Starter App by adding persistent local (on-device) data storage using the SharedPreferences package. This package provides a common method of storing key-value data locally for both iOS and Android. It avoids the overhead of file-handling (e.g. paths, open, close, check for existence, read, write, seek, permission-to-read/write?), allowing us to treat the store more like a map (aka associative array, hash-map, or dictionary, depending on your programming language roots).

But really, how?

Bring on the coding! To begin with either:

  • Use (or clone) your finished project from the previous article as the starting point today, or…
flutter packages get

Open the pubspec.yaml file and add below the “provider: …” line:

shared_preferences: ^0.5.4+8

Also, update the name and description lines at the top of the file. Once you have updated the name (e.g. to flutter_persistent_local), you may also need to update the test/widget_test.dart file where an import line will be broken due to the package name change. Then run:

flutter packages get

Next, move the CounterState class out into its own file: Create the file lib/counter_state.dart and cut-n-paste the CounterState class definition out of main.dart into this new file. Add these imports to the top of the new file:

import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

The first line will resolve the errors in CounterState class, while the second line we will use soon. Back in the main.dart file, add this import just below the existing ones at the top of the file:

import 'counter_state.dart';

At this point the app should compile and run as before. Go ahead and test it with a simulator/emulator:

flutter run

Time now to modify our CounterState class to include persistent storage of our state data. This is by far the most involved part of this article, but well worth the journey.

Previously we initialised the _value property to 0 every time we created an instance of CounterState. We will no longer do that, as instead we’ll be loading its value from SharedPreferences. So, modify the combined declaration/initialisation of _value to be a simple declaration only, i.e.:

int _value;

Also, near the top of the class definition, add the line below. We will use this constant as the key when loading/storing our state data (_value) from/to the key-value store of SharedPreferences. The string literal 'counterState' can be any non-empty string, but should be meaningful, and after the code is run once (i.e. a value is stored with that key), it should not be changed again:

static const _sharedPrefsKey = 'counterState';

Loading/storing values from/to SharedPreferences is asynchronous. Even though it does not involve any network activity, the app is still interacting with the underlying OS (iOS or Android) and filesystem. Much of the complexity of that interaction is taken away from us — but not the fact that we have to wait on most of the calls. Most of the time it is very fast. However we must treat it much the same as in any other async situation.

This means that our code has to:

  • wait for some functions to return values (using await)

So, we will add some transient state properties for this. Just below the declaration of _value and the getter value, add these lines:

// transient state - i.e. will not be stored when the app is not running
// internal-only readiness- & error-status
bool _isWaiting = true;
bool _hasError = false;
//
// read-only status indicators
bool get isWaiting => _isWaiting;
bool get hasError => _hasError;

The values of _isWaiting and _hasError will be set accordingly while waiting for data to be loaded/saved, when data is loaded, when saves are complete, when errors occur, and when operations complete successfully. Any time that any of our state properties change (i.e. _value, _isWaiting, _hasError), we need to call notifyListeners() so the the UI knows to update accordingly. We will see this below.

The most substantial new method to add will take care of loading and saving _value. Let’s start by adding this:

// helper to do the actual storage-related tasks
// handles both initial-load & save since they only differ by essentially 1 line
// - getInt vs setInt
void _store({bool load = false}) async {
_hasError = false;
_isWaiting = true;
notifyListeners();
// artificial delay so we can see the UI changes
await Future.delayed(Duration(milliseconds: 500));
//
try {
final prefs = await SharedPreferences.getInstance();
if (load) {
_value = prefs.getInt(_sharedPrefsKey) ?? 0;
} else {
// save
// uncomment this to simulate an error-during-save
// if (_value > 3) throw Exception("Artificial Error");
await prefs.setInt(_sharedPrefsKey, _value);
}
_hasError = false;
} catch (error) {
_hasError = true;
}
_isWaiting = false;
notifyListeners();
}

Some explanation of the above:

  • we start by setting _isWaiting to true (essentially saying we’re busy until this method completes), and _hasError to false (nothing has gone wrong, yet) and notifying listeners (which happens to be the UI).

With that method in place, we now define these convenience methods that leverage it for their respective operations:

void _load() => _store(load: true);
void _save() => _store();

The _load() will be used just once, when we create a new instance of CounterState. For that to happen, we need an explicit constructor for the class. Add it here:

// default Constructor - loads the latest saved-value from disk
CounterState() {
_load();
}

The _save() will be used every time we update _value, to ensure our runtime state and persistent state are in sync. So we’ll define a new helper method _setValue, like so:

void _setValue(int newValue) {
_value = newValue;
_save();
}

We will change the definition of increment() and add a new method, reset(), that will set _value back to 0 — with a new control added to the UI to access this later (and as already shown in the GIF above). Delete the previous definition of increment and add these lines:

void increment() => _setValue(_value + 1);
void reset() => _setValue(0);

So that completes the transformation of our data-model class CounterState to include persistent storage.

Time to update the UI. Open main.dart. There are no changes to the MyApp widget. The MyHomePage widget will undergo some change though. Delete the convenience definitions of _counter and _incrementCounter() inside build.

Then locate the Column children lines:

children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],

and replace with these lines:

children: <Widget>[
Text(
counterState.hasError
? ''
: counterState.isWaiting
? 'Please wait...'
: 'You have pushed the button this many times:',
),
counterState.hasError
? Text("Oops, something's wrong!")
: counterState.isWaiting
? CircularProgressIndicator()
: Text(
'${counterState.value}',
style: Theme.of(context).textTheme.display1,
),
],

In the above we’re using the values (true/false) of counterState.hasError and counterState.isWaiting to dictate the information displayed on the screen. We always follow this order: check for error, then check whether we’re still waiting, and if those are all false we finally we go ahead with the main information display. Every time notifyListeners() is called from methods of counterState, the screen will update accordingly. That half-second delay we added in CounterState._store ensures that we have some time to see the CircularProgressIndicator spinning on the screen (i.e. it ensures that counterState.isWaiting == true for long enough for us to see it). If CounterState.hasError is true, then we’ll get the “Oops, something’s wrong!” message, etc.

Finally, as a small enhancement to the UI, replace the lone “increment” floating-action-button:

floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),

with these lines, to get a “reset-to-zero” button to accompany it:

floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
FloatingActionButton(
child: Icon(Icons.undo),
// colours indicate when the button is inactive (i.e when counterState is waiting)
backgroundColor: counterState.isWaiting
? Theme.of(context).buttonColor
: Theme.of(context).floatingActionButtonTheme.backgroundColor,
// the button action is disabled when counterState is waiting
onPressed: counterState.isWaiting ? null : counterState.reset,
),
FloatingActionButton(
child: Icon(Icons.add),
// colours indicate when the button is inactive (i.e when counterState is waiting)
backgroundColor: (counterState.isWaiting || counterState.hasError)
? Theme.of(context).buttonColor
: Theme.of(context).floatingActionButtonTheme.backgroundColor,
// the button action is disabled when counterState is waiting
onPressed: (counterState.isWaiting || counterState.hasError)
? null : counterState.increment,
),
],
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,

Again, we use counterState.hasError and counterState.isWaiting to determine whether the buttons are enabled (onPressed is non-null) and colour them appropriately.

So we’re done with the code, go ahead and run the app:

flutter run

Even more importantly, after a few clicks, quit the app (while the counter is not zero(!)) and then run it again (on the same simulator, emulator or device!):

flutter run

This time the app will start with the counter showing the same value as when you previously quit it. Note that the value only persists when started/re-started on the same simulator, emulator or device. Our state is persistent, but still private to the specific device that created it.

For your reference, the repo for the final product is at https://github.com/eggzotic/flutter_persistent_local.

Prefer SharedPreferences?

SharedPreferences provides simple and convenient local on-device storage for Flutter Apps. It’s also fast. And it’s free, on your own device.

But…beware these limitations and caveats:

  • SharedPreferences does not provide a means to sync writes to disk. The underlying OS (iOS or Android) remains in control of this.

So, stay tuned for the next edition where we will look at alternative persistent storage methods that overcome some of the above short-comings.

Next episode: Cloud-based Data Persistence in Flutter

Previous episode: Stateless Flutter

The Startup

Medium's largest active publication, followed by +584K people. Follow to join our community.

Richard Shepherd

Written by

Flutter enthusiast. I enjoy learning & creating with Flutter for mobile. Also enjoy cruising SE Asia on Motorcycle. Oh and messing about with sourdough.

The Startup

Medium's largest active publication, followed by +584K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade