Simplifying SharedPreferences in Flutter with a few lines

Andrew Chen
6 min readNov 8, 2024

--

Yokohama Arena, Japan, Sep. 2024

Simplifying Preference Management with Custom Wrappers

When working with persistent data in Flutter apps, SharedPreferences is a common choice. However, directly accessing and modifying preferences can quickly become verbose and repetitive, especially if the app grows and more preferences are added. In this post, we'll explore a new way to streamline preference management in Dart by introducing a wrapper around SharedPreferences using a custom Preference interface.

The Traditional Approach

Consider the following example using SharedPreferences. Here, we want to check if the user has been shown a showcase tutorial, and if not, we display it and update the preference:

if (sharedPreferences.getBool("hasShowcased") != true) {
sharedPreferences.setBool("hasShowcased", true);
showShowcase();
}

This code is straightforward but has a few drawbacks:

  1. Verbosity: Each time we need to get or set a preference, we must remember to use the correct SharedPreferences method (getBool, getString, etc.), making the code verbose and prone to errors.
  2. Consistency: If we need to modify or remove this preference elsewhere, we must duplicate the key ("hasShowcased") and the logic each time.
  3. Scalability: As the number of preferences grows, managing all these keys and types becomes cumbersome.

The Solution: Encapsulating Preferences

To address these issues, we can encapsulate the logic for handling preferences within a Preference class that provides a cleaner, type-safe API. Here’s what the optimized approach looks like:

final hasShowcasedPref = sharedPreferences.asBool("hasShowcased");
if (hasShowcasedPref.value != true) {
hasShowcasedPref.value = true;
showShowcase();
}

This version has several benefits:

  • Readability: The intent of the code is much clearer. By wrapping the preference in a hasShowcasedPref object, the code reads more naturally.
  • Type Safety: By defining preferences as types, we reduce the risk of errors caused by mistyped keys or incorrect type methods.
  • Reusability: The same Preference object can be reused in different parts of the codebase, making it easier to manage preference keys consistently.

Another example:

Without Preference Delegation:

if (isLogin && sharedPreferences.getBool("hasLoginShowcased") != true) {
sharedPreferences.setBool("hasLoginShowcased", true);
showShowcase();
} else if (sharedPreferences.getBool("hasShowcased") != true) {
sharedPreferences.setBool("hasShowcased", true);
showShowcase();
}

With Preference Delegation:

final hasShowcasedPref = sharedPreferences.asBool(isLogin ? "hasLoginShowcased" : "hasShowcased");
if (hasShowcasedPref) {
hasShowcasedPref.value = true;
showShowcase();
}

Under the Hood: How It Works

To implement this approach, we create an abstract Preference class and a concrete implementation, BasePreference. The Preference class defines a common interface for interacting with preferences, while BasePreference handles the core logic. Here’s the code:

abstract class Preference<T> {
String get key;
T? get value;
set value(T? newValue);

}


class BasePreference<T> implements Preference<T> {
const BasePreference(
this.key,
this._getter,
this._setter,
this._remove,
);

@override
final String key;
final T? Function(String key) _getter;
final Future<bool> Function(String key, T value) _setter;
final Future<bool> Function(String key) _remove;

@override
T? get value => _getter(key);

@override
set(T? newValue) => newValue == null
? remove()
: _setter(key, newValue);

@override
Future<bool> remove() => _remove(key);
}
  1. Preference<T>: This interface defines the basic operations for accessing and modifying a preference. It ensures consistency across all preference types (like bool, String, etc.).

ref. https://gist.github.com/yongjhih/5d8f74c11c5331bf05b74baa133a9787

Explanation of Key Components

  1. BasePreference<T>: The generic class implements Preference<T> and uses function references (_getter, _setter, _remove) to interact with SharedPreferences functions. This way, BasePreference can support any data type without requiring repeated code for each type.
  2. Extension on SharedPreferences: We add an extension to make it easy to get instances of Preference for specific types, such as bool, String, int, double, and List<String>:
extension SharedPreferencesX on SharedPreferences {
Preference<bool> asBool(String key) => asPreference(key, getBool, setBool);
Preference<String> asString(String key) => asPreference(key, getString, setString);
Preference<int> asInt(String key) => asPreference(key, getInt, setInt);
Preference<double> asDouble(String key) => asPreference(key, getDouble, setDouble);
Preference<List<String>> asStringList(String key) => asPreference(key, getStringList, setStringList);

Preference<T> asPreference<T>(String key,
T? Function(String key) getter,
Future<bool> Function(String key, T value) setter,
) => BasePreference(key, getter, setter, remove);
}

Using the New Preference API

With this wrapper in place, managing preferences becomes easier and more consistent. Here’s an example of setting and getting a string preference:

final nicknamePref = sharedPreferences.asString("nickname");
print("Hello, ${nicknamePref.value}");
nicknamePref.value = "Andrew";

Using PreferenceSwitch in Your UI

Now let’s see how we can use PreferenceSwitch in the main app. First, you need to initialize SharedPreferences and create a Preference<bool> using the asBool extension:

Column(
children: [
ListTile(
title: const Text("Dark Mode Enabled"),
trailing:
PreferenceSwitch(
preference: sharedPreferences.asBool("darkModeEnabled"),
),
),
ListTile(
title: const Text("Nickname Enabled"),
trailing:
PreferenceSwitch(
preference: sharedPreferences.asBool("nicknameEnabled"),
),
),
],
)

Creating a PreferenceSwitch Widget

To make it even easier to work with preferences in the UI, we can create a PreferenceSwitch widget that combines a Switch widget with a Preference<bool>. This widget reads the initial preference value, displays it, and updates the preference automatically when toggled.

Here’s the code for PreferenceSwitch:

class PreferenceSwitch extends HookWidget {
final Preference<bool> preference;

const PreferenceSwitch({
Key? key,
required this.preference,
}) : super(key: key);

@override
Widget build(BuildContext context) {
final preferenceState = useState(preference.value ?? false);

return Switch(
value: state.value,
onChanged: (newValue) {
preferenceState.value = newValue;
preference.value = newValue;
},
);
}
}

Using this Preference wrapper for SharedPreferences results in a cleaner, more maintainable way to handle preferences in Flutter apps. By adding a PreferenceSwitch widget, we can also streamline the UI, making it easier to build reusable and consistent settings components. This structure not only improves readability and consistency but also makes it easier to extend and refactor your code as your application grows.

Usage in PreferenceSwitch

You can use a asState hook in PreferenceSwitch as if it were useState, but it will automatically persist changes to SharedPreferences.

  • The asState hook provides a ValueNotifier<T?> similar to useState and triggers a rebuild whenever the value changes.
  • This implementation ensures that changes to the ValueNotifier automatically sync with the underlying preference, making it easy to use in Flutter widgets.
class PreferenceSwitch extends HookWidget {
...

@override
Widget build(BuildContext context) {
- final preferenceState = useState(preference.value ?? false);
+ final preferenceState = preference.asState();

return Switch(
value: state.value,
onChanged: (newValue) {
preferenceState.value = newValue; // This updates both UI and preference
- preference.value = newValue;
},
);
}
}

Implementation of asState as a Custom Hook

To make Preference<T>.asState() behave like useState(), we need to create a custom hook that directly integrates with Flutter Hooks, as useState() does. This will allow us to automatically trigger a widget rebuild when the preference value changes, while ensuring updates are persisted to SharedPreferences.

We’ll implement asState as a custom hook that utilizes useState under the hood and syncs with the Preference<T>. Here’s how:

  1. Define asState as an extension method on Preference<T>.
  2. Inside asState, use useState to hold the preference's value locally in the widget.
  3. Return a tuple with the current value and a setter function that both updates useState (to trigger rebuilds) and updates the underlying Preference<T>.

Here’s how you can do this:

extension PreferenceStateX<T> on Preference<T> {
/// Custom hook to get and set the preference value, treating it like useState().
ValueNotifier<T?> asState() {
final state = useState<T?>(value); // Initialize state with preference value

// Sync changes back to the preference whenever state.value changes.
useEffect(() {
final listener = () {
value = state.value; // Update preference whenever state changes
};
state.addListener(listener);

// Clean up the listener when the widget is disposed
return () => state.removeListener(listener);
}, [state]);

return state;
}
}
  1. Initialize Local State: useState<T?> is initialized with the current preference value.
  2. Listener for Synchronization: useEffect sets up a listener on state. Whenever state.value changes, it updates Preference.value (saving it to SharedPreferences).
  3. Cleanup: The listener is removed when the hook is disposed to avoid memory leaks.

Benefits Recap

By encapsulating SharedPreferences in a Preference class, we gain:

  • Type Safety: Each preference is guaranteed to be accessed with the correct type.
  • Reusability: We can define the preference once and reuse it throughout the app, reducing the risk of inconsistencies.
  • Readability: The preference API makes code cleaner and easier to understand.
  • Flexibility: Adding new preference types (like List<String>) is straightforward and doesn’t require repeating the core logic.

Conclusion

Using this wrapper around SharedPreferences results in a cleaner, more maintainable, and more scalable way to handle preferences in Flutter apps. This structure not only improves readability and consistency but also makes it easier to extend and refactor your code as your application grows.

--

--

No responses yet