Every iOS & MacOS developer has subtly different opinions on how client architecture should work. From the classic MVC Pattern built into Apple's own frameworks (read: Massive View Controllers), to those other MV* patterns (MVP, MVVM), to the scary sounding Viper, how do we choose?
This article won't answer that question for you, because the true answer is that it depends on context. What I do want to highlight is a fundamental approach I've been seeing more often that I really like, the state container.
What is a State Container?
Essentially, state containers are just wrappers around pieces of information, gatekeepers for inputs & outputs to protected data. They don't care too much about the type of data or where it originally came from. But they do care, a lot, about when it is changed. The central doctrine of a state container is that the effect of any state change should propagate across the application in an organized and predictable fashion.
State containers provide state safety in the same way thread locks provide thread safety.
This isn't a new concept, and it's not a toolkit you can build your entire app with. The state container idea is versatile enough to fit into any app architecture without imposing too many rules. But it's a powerful approach that is at the crux of popular libraries like ReactiveReSwift, which is itself based on ReSwift, which is itself based on Redux, which is itself based on Flux...and the list goes on. The success and sheer number of these frameworks speaks to the efficacy of the state container pattern in modern mobile applications.
In terms of a reactive library like ReSwift
, state containers bridge the gap between an Action
and a View
as part of a uni-directional data flow. Yet even without the other 2 components state containers can be powerful. In fact, they can do much more than these libraries use them for.
In this article I'll demonstrate a basic state container implementation that I've employed for all sorts of projects that didn't warrant pulling in larger architectural libraries.
Building a State Container
Let's start by constructing our fundamental State
class.
/// Wraps a piece of state.
class State {
/// Unique key used to identify the state across the application.
let key: String
/// Holds the state itself.
fileprivate var _value: Type
/// Used to synchronize changes to the state value.
fileprivate let lockQueue: DispatchQueue
/// Create a state container with the provided `defaultValue`, and associate it with a `key`.
init(_ defaultValue: Type, key: String) {
self._value = defaultValue
self.key = key
self.lockQueue = DispatchQueue(label: "com.stateContainers.\(key)", attributes: .concurrent)
}
/// Invoke this method after manipulating the state.
func didModify() {
print("State for key \(self.key) modified.")
}
}
This generic class wraps a _value
of any Type
, associates it with a key
and declares an initializer to provide a defaultValue
.
Reading State
In order to read the current value of our state container, we'll create a computed value
property.
Since state changes are typically triggered and read from multiple threads, we'll use a basic Readers-Writer Lock with Grand Central Dispatch to enforce thread safety when accessing the internal _value
property.
extension State {
/// The current state value.
var value: Type {
var retVal: Type!
self.lockQueue.sync {
retVal = self._value // I wish there was a `sync` method that inferred a generic return value.
}
return retVal
}
}
Modifying State
In order to modify state, we'll create a modify(_newValue:)
method. Although we could have allowed the setter to be accessed directly, the goal here is to define structure around state modifications. Using a method over a simple property setter communicates through our API that modifying state has repercussions. Therefore, all state changes must happen through this method.
extension State {
/// Modifies the receiver by assigning the `newValue`.
func modify(_ newValue: Type) {
self.lockQueue.async(flags: .barrier) {
self._value = newValue
}
// Handle the repercussions of the modification
self.didModify()
}
}
Just for fun, let's also define a custom operator!
/// Modifies the receiver by assigning the right-hand side of the operator.
func ~> (lhs: State, rhs: T) {
lhs.modify(rhs)
}
The didModify()
Method
didModify()
is the most important piece of our state container, because it allows us to define behaviors that are triggered by state changes. Subclasses of State
can override this method to perform custom logic whenever this happens.
The didModify()
method also plays another role. If our generic Type
is a class
, it is possible to alter its properties without our state container knowing about it. Therefore we expose the didModify()
method so that these types of changes can be propagated manually (see below).
This is an inherent danger of using reference types when dealing with state, so I recommend using value types whenever possible.
Using the State Container
Here's the most basic example of how to use our new State
class:
// State wrapping a value type
let themeColor = State(UIColor.blue, key: "themeColor")
print(themeColor.value) // "UIExtendedSRGBColorSpace 0 0 1 1"
We can use Optional
types as well:
// State wrapping an optional value type
let appRating = State(nil, key: "appRating")
print(String(describing: appRating.value)) // "nil"
Modifying our state is easy:
appRating.modify(4)
print(String(describing: appRating.value)) // "Optional(4)"
appRating ~> nil
print(String(describing: appRating.value)) // "nil"
If we have non value types (e.g. types that don't trigger didSet
during in-place modification), we invoke the didModify()
method to let State
know about the change:
class CEO : CustomDebugStringConvertible {
var name: String
init(name: String) {
self.name = name
}
var debugDescription: String {
return name
}
}
// State wrapping a reference type
let currentCEO = State(CEO(name: "John Sculley"), key: "currentCEO")
print(currentCEO.value) // "John Sculley"
// Assigning a new user property, no need to invoke `didModify`
currentCEO ~> CEO(name: "Steve Jobs")
print(currentCEO.value) // "Steve Jobs"
// Modifying the user in-place, need to invoke `didModify` manually
currentCEO.value.name = "Tim Cook"
currentCEO.didModify()
print(currentCEO.value) // "Tim Cook"
Although calling didModify()
manually is annoying, there's no current way of knowing if a reference type's internal properties have been modified since they are mutable in-place. If you can think of a good way around this, tweet me @TTillage!
Listening to State Changes
Now that we've established a basic state container, let's expand it to be more powerful. Through our didModify()
method we can add functionality in the form of specialized subclasses. Let's add a way to "listen" for changes in state, so our UI components can update themselves whenever changes occur.
Defining a StateListener
First, let's define what a state listener looks like:
protocol StateListener : AnyObject {
/// Invoked when state is modified.
func stateModified(_ state: State)
/// The queue to use when dispatching state modification messages. Defaults to the main queue.
var stateListenerQueue: DispatchQueue { get }
}
extension StateListener {
var stateListenerQueue: DispatchQueue {
return DispatchQueue.main
}
}
Whenever state changes, a listener will receive an invocation of stateModified(_state:)
on the stateListenerQueue
that it chooses, defaulting to DispatchQueue.main
.
Creating the MonitoredState
Subclass
Next let's define a specialized subclass called MonitoredState
, which keeps weak references to our listeners and notifies them of state changes. An easy way to do this is to use NSHashTable.weakObjects()
.
class MonitoredState : State {
/// Weak references to all the state listeners.
fileprivate let listeners: NSHashTable
/// Used to synchronize changes to the listeners.
fileprivate let listenerLockQueue: DispatchQueue
/// Create a state container with the provided `defaultValue`, and associate it with a `key`.
override init(_ defaultValue: Type, key: String) {
self.listeners = NSHashTable.weakObjects()
self.listenerLockQueue = DispatchQueue(label: "com.stateContainers.listeners.\(key)", attributes: .concurrent)
super.init(defaultValue, key: key)
}
/// All of the listeners associated with the receiver.
var allListeners: [StateListener] {
var retVal: [StateListener] = []
self.listenerLockQueue.sync {
retVal = self.listeners.allObjects.map({ $0 as? StateListener }).flatMap({ $0 }) // remove `nil` values
}
return retVal
}
/// Notifies all listeners that something changed.
override func didModify() {
super.didModify()
let allListeners = self.allListeners
let state = self
for l in allListeners {
l.stateListenerQueue.async {
l.stateModified(state)
}
}
}
}
Our MonitoredState
class invokes stateModified(_state:)
on its listeners whenever its own didModify
method is called. Easy!
To add a listener, we'll define an attach(listener:)
method. Just like earlier, we're use listenerLockQueue
to set up a readers-writer lock on our listeners
property.
extension MonitoredState {
/// Associate a listener with the receiver's changes.
func attach(listener: StateListener) {
self.listenerLockQueue.sync(flags: .barrier) {
self.listeners.add(listener as AnyObject)
}
}
}
Now it's possible to listen to changes to any value that is wrapped in MonitoredState
!
Triggering UI Updates from State Changes
Here's an example of how to utilize our new MonitoredState
class. Let's say we keep track of the device's location in a MonitoredState
container:
/// The device's current location.
let deviceLocation = MonitoredState(nil, key: "deviceLocation")
We also have a view controller which displays the device's current location on a map:
// Centers a map on the devices's current location
class LocationViewController : UIViewController {
@IBOutlet var mapView: MKMapView!
override func viewDidLoad() {
super.viewDidLoad()
self.updateMapForCurrentLocation()
}
func updateMapForCurrentLocation() {
if let currentLocation = deviceLocation.value {
// Center the map on the device's location
self.mapView.setCenter(currentLocation.coordinate, animated: true)
}
}
}
Since we need to update the map when deviceLocation
changes, we'll extend LocationViewController
to be a StateListener
:
extension LocationViewController : StateListener {
func stateModified(_ state: State) {
if state === deviceLocation {
print("Location changed, updating UI")
self.updateMapForCurrentLocation()
}
}
}
Then we'll remember to attach the view controller to the state using `attach(listener:)`. In practice, this could happen in viewDidLoad
, init
, or whenever you want the listening to start.
let vc = LocationViewController()
deviceLocation.attach(listener: vc)
Now that we're listening to deviceLocation
, once we get a new location lock from CoreLocation
all we have to do is modify our state container and our view controller will update itself automatically!
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let closestLocation = locations.first {
// Triggers `updateMapForCurrentLocation` on the VC asynchronously on the main queue
deviceLocation ~> closestLocation
}
}
Note that since we're using a weak referencing NSHashTable
, when the view controller is deallocated it will no longer appear in the allListeners
property of deviceLocation
. There is no need to "remove" a listener.
Remember that in a real-world scenario you'd want to check to ensure the view controller's view
is visible before performing a UI update.
Persisting State
Ok, now we're getting to the good stuff. We have everything we need to be able to take a state container and persist it anywhere we want.
- We have a unique
key
that can be used to associate with a backing store. - We know the
Type
of the value, informing how it should be persisted. - We know when the value needs to be loaded from storage, using our
init(_defaultValue:key:)
method. - We know when the value needs to be persisted to storage, using our
didModify()
method.
Backing with UserDefaults
Let's make a state container that automatically persists any changes to UserDefaults.standard
and re-loads any previous value when initialized. It'll support values that are both Optional
and non-Optional
. It'll also automatically serialize & deserialize types that conform to NSCoding
since UserDefaults
doesn't support using NSCoding
directly.
Here's the code, I'll break it down below.
class UserDefaultsState : MonitoredState {
/// 1) Loads existing value from `UserDefaults.standard` if it exists, otherwise falls back to the `defaultValue`.
public override init(_ defaultValue: Type, key: String) {
let existingValue = UserDefaults.standard.object(forKey: key)
if let existing = existingValue as? Type {
// 2) Non-NSCoding value
print("Loaded \(key) from UserDefaults")
super.init(existing, key: key)
} else if let data = existingValue as? Data, let decoded = NSKeyedUnarchiver.unarchiveObject(with: data) as? Type {
// 3) NSCoding value
print("Loaded \(key) from UserDefaults")
super.init(decoded, key: key)
} else {
// 4) No existing value
super.init(defaultValue, key: key)
}
}
/// 5) Persists any changes to `UserDefaults.standard`.
public override func didModify() {
super.didModify()
let val = self.value
if let val = val as? OptionalType, val.isNil {
// 6) Nil value
UserDefaults.standard.removeObject(forKey: self.key)
print("Removed \(self.key) from UserDefaults")
} else if let val = val as? NSCoding {
// 7) NSCoding value
UserDefaults.standard.set(NSKeyedArchiver.archivedData(withRootObject: val), forKey: self.key)
print("Saved \(self.key) to UserDefaults")
} else {
// 8) Non-NSCoding value
UserDefaults.standard.set(val, forKey: self.key)
print("Saved \(self.key) to UserDefaults")
}
UserDefaults.standard.synchronize()
}
}
init(_defaultValue:key:)
- Our initializer checks to see if
UserDefaults.standard
already contains a value for the providedkey
. - If we're able to load an object which matches our generic
Type
, we can use it immediately. - If we're able to load
Data
and unarchive it usingNSKeyedUnarchiver
, it was stored usingNSCoding
and we can use it immediately. - If nothing exists in
UserDefaults.standard
matching thekey
we'll use thedefaultValue
provided.
didModify()
- Whenever the state changes, we want to persist our state automatically. The method of doing so depends on the
Type
. - If the generic
Type
is anOptional
and it'snil
, we need to simply remove the value fromUserDefaults.standard
. It's a little tricky to check if a generic type isnil
, but one way to do it is to extendOptional
with a custom protocol:
protocol OptionalType {
/// Whether the receiver is `nil`.
var isNil: Bool { get }
}
extension Optional : OptionalType {
public var isNil: Bool {
return self == nil
}
}
- If our value conforms to
NSCoding
, we'll convert it toData
usingNSKeyedArchiver
and then persist it. - Otherwise, we'll just save the value directly to
UserDefaults
.
Now if we want our state to be backed by UserDefaults
, all we have to do is use our new UserDefaultsState
class!
UserDefaults.standard.set(true, forKey: "isTouchIDEnabled")
UserDefaults.standard.synchronize()
let isTouchIDEnabled = UserDefaultsState(false, key: "isTouchIDEnabled")
print(isTouchIDEnabled.value) // "true"
isTouchIDEnabled ~> false
print(UserDefaults.standard.bool(forKey: "isTouchIDEnabled")) // "false"
Our UserDefaultsState
will automatically update its backing store whenever its value changes. Between app launches, it'll automatically pull the existing value from UserDefaults
to be used immediately.
Backing with Other Data Stores
This is just one example of how State
can be extended to intelligently store its own data. In my projects I have also built subclasses which persist asynchronously to disk or to the keychain when changes occur. You could even trigger synchronization with a remote server, or log metrics to an Analytics library all by just using a different subclass. The sky is the limit.
Managing State at the App Level
So where should these state containers be kept? Typically I store them statically in a single struct
, and access them from all over the app. This is similar to how the Flux-based libraries store global app state.
struct AppState {
static let themeColor = State(UIColor.blue, key: "themeColor")
static let appRating = State(nil, key: "appRating")
static let currentCEO = State(CEO(name: "Tim Cook"), key: "currentCEO")
static let deviceLocation = MonitoredState(nil, key: "deviceLocation")
static let isTouchIDEnabled = UserDefaultsState(false, key: "isTouchIDEnabled")
}
You can scope the state containers however you want, in separate or embedded structs and with varying access levels.
Conclusion
Managing state inside state containers has many benefits. Data that was previously buried inside a singleton, or being passed around in a web of delegation, is now surfaced and visible at a high-level. All of the many inputs into your application's behavior are suddenly visible and organized.
From API responses to feature toggles to protected keychain items, using a state container pattern is a great way to define structure around critical pieces of information. State containers can easily be used for caching, user preferences, analytics, and anything that needs to stick around between app launches.
The state container pattern allows UI components to stop worrying about how & when data is generated, and start focusing on how data is converted into a fantastic user experience.