Local Data Persistence in Flutter

Save my State

Richard Shepherd
The Startup
11 min readDec 11, 2019

--

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
  • via key-value storage such as SharedPreferences

Off-device storage such as:

  • in remote object storage e.g. S3
  • in a relational-database (e.g. MySQL, PostgreSQL, …)
  • in a NoSQL database (e.g. MongoDB, Google Cloud Firestore)

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…
  • …clone the repo from https://github.com/eggzotic/flutter_create_stateless to your development environment — call this new repo “flutter_persistent_local”. This is the repo of the finished-app from my previous article Stateless Flutter, and it serves as our starting point for this new version. After pulling that down, you may need to run this at the terminal, from the top-level folder of the project, to resolve the initial errors in your IDE:
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)
  • indicate to the UI when waiting is required (e.g. introduce some transient state property)
  • be aware that errors can occur, beyond its control, that the user needs to know about, and the UI needs to handle gracefully (e.g. again, introduce some transient state property)

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).
  • later, we will see how we use these to update the UI to indicate we’re waiting for something to complete
  • this situation applies to both load and save — both involve async operations
  • a half-second artificial delay is added here — enough time so that we can clearly see the UI update to reflect this “we’re waiting” state. You would not do this in a real app(!)
  • then we wait for a handle to the SharedPreferences shared instance. Even though this typically is very quick, it is not a synchronous operation and so we need to use await to ensure the code does not continue before we have that handle
  • next, we either load (getInt) or save (setInt) _value.
  • We’re using a single method to cover both load and save since the code required for these operations differ only in those 2 lines
  • We use the try { …} catch() { …} blocks to allow for possible errors that may occur while interacting with SharedPreferences.
  • If errors do occur, we set _hasError to true
  • If no errors occurred, we likewise set _hasError to false
  • Regardless, we finally set _isWaiting to false and notify the listeners.
  • in case you want to see what happens when an error occurs, one way this can be done is to uncomment the line in the code above where it throws an artificial exception. The value “3” can be set to anything — just click the increment button until that value is exceeded and you will get an error.

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.
  • Due to the underlying platform-specific mechanisms (iOS — NSUserDefaults, Android — SharedPreferences) there will be various limitations:
  • simple builtin types only — e.g. numbers, strings, bool, binary-data
  • size-limits, entry-count-limits
  • storing other data-types typically requires encoding/decoding for every load and save operation — which means more effort in your coding to perform these operations, and may take more CPU resource and time to complete, which may impact the user-experience (and the device battery consumption)
  • When an App is deleted from the simulator/emulator/device then the data in SharedPreferences is also gone. Permanently.
  • There is no easy way to backup/protect the data stored in SharedPreferences, apart from device-level mechanisms (e.g. iCloud for iOS).
  • There is no easy way to inspect/debug the data stored in SharedPreferences, outside of the App that created the data.
  • during development you may add code/screens to read and display the data on your own devices and sims — this represents effort you may have to redo for every app
  • but once your App is “in the wild”, on other people’s devices — debugging issues related to their data-content is difficult, if not impossible
  • Related to the above point, if you add properties to your data-structures (e.g. in a later version of the App), when your code runs it needs to account for the data in SharedPreferences not having all these new properties. This can be harder to work with when you do not have another tool to inspect the current data.
  • any type of local-storage does not provide for data-sharing between devices and users

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

--

--

Richard Shepherd
The Startup

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