Article
My Favorite Design Patterns: State
October 20, 2021
There’s a particular experience that every software engineer must have. A bit like an inevitable rite-of-passage. It’s the day you’re so frustrated by a piece of code that you want to publicly humiliate the fool who wrote it. If only your brain weren’t melting all over your desk perhaps you could muster the strength. You reach out to your trusty friend, git blame
, to expose the identity of your new mortal enemy. But instead, you find your own name along with a humble pill. That is a difficult pill to swallow.
Through rigorous scientific testing, I’ve discovered that the easiest way to conjure this experience is through the use of flags. Flags always start out innocently — after all, it’s just a teeny tiny, itty bitty, boolean. No big harm ever came from so few bits! But once a flag has been added, it becomes an acceptable way to handle future challenges — just follow the conventions established in the current code! Each new flag digs the the pit of despair a little deeper. With just 3 flags, the code could be in 1 of 8 different states during run time. 8 states doesn’t sound too bad. But consider that these state changes often depend on user input, availability of resources, and current system state. Now sprinkle state changes among logic for performing animations, validating user input, and error handling. Reconstructing that state machine in your head several times per day between meetings is a recipe for brain soup. My favorite way around the pit of despair is using the State pattern.
The idea of the State pattern is to move all state-specific behavior and state-transition logic behind a single interface. The benefits are pretty impressive.
- Testing state is easy.
- Adding and removing states is trivial.
- Logic for transitioning between states is straightforward.
- Modifying state transition logic is simple.
There are three components to the State pattern.
- Context — The interface for accessing state information.
- State — Defines the behavior of each state.
- State Transitions — The logic for when and how the state transitions are made.
The fun part about using the State pattern in Swift is that it can scale based on needs. For simple situations, an enum
will do the job. For more complex situations, it is warranted to use a protocol along with a handful of structs and classes.
Let’s take a look at how the State pattern is implemented with an example. During video playback, a lot of state needs to be tracked. Is the video playing? What icon gets displayed on the action button? What action is performed when the button is pressed? Are the controls displayed or hidden? A seemingly simple interface can explode quickly with complexity. Luckily, this is no match for the State pattern!
A More “Traditional” Approach
In this example, we’ll define a State protocol along with two structs, Playing
and Paused
, to track the current state. We'll also create a Context
to own the state and provide an interface for consumers. Notice that the State protocol and the two concrete implementations are marked private. There's no need for consumers to know what states are possible or when state transitions happen.
import SwiftUI
// Defines the possible actions that can be taken on the various
// states.
private protocol State {
var currentTimestamp: TimeInterval { get }
var image: Image { get }
var shouldHideControls: Bool { get }
// Returning an instance of VideoState is how transitions
// in the state machine are defined.
func play() -> State
func pause() -> State
}
// One of the states that our code could be in.
private struct Playing: State {
let image: Image = .init("pause.png")
let shouldHideControls = true
let startTime: Date
var currentTimestamp: TimeInterval {
return Date().timeIntervalSince(startTime)
}
init(currentTimestamp: TimeInterval) {
self.startTime = Date(timeInterval: -currentTimestamp, since: Date())
}
func play() -> State {
return self
}
func pause() -> State {
return Paused(currentTimestamp: currentTimestamp)
}
}
// One of the states that our code could be in.
private struct Paused: State {
var currentTimestamp: TimeInterval
let image: Image = .init("play.png")
let shouldHideControls = false
init(currentTimestamp: TimeInterval = 0.0) {
self.currentTimestamp = currentTimestamp
}
func play() -> State {
return Playing(currentTimestamp: currentTimestamp)
}
func pause() -> State {
return self
}
}
// An added bonus. By adding the delegate callback like this,
// all the logic for updating UI state can be consolidated
// into one place.
protocol ContextDelegate {
func playbackStateChanged()
}
// The interface to our state.
class Context {
var delegate: ContextDelegate?
private var state: State = Paused() {
didSet {
delegate?.playbackStateChanged()
}
}
var currentTimestamp: TimeInterval {
state.currentTimestamp
}
var image: Image {
state.image
}
var shouldHideControls: Bool {
state.shouldHideControls
}
func play() {
state = state.play()
}
func pause() {
state = state.pause()
}
func stop() {
state = state.pause()
}
}
Another way to implement the State pattern in Swift is using an enum.
Implementing the State Pattern with an Enum
In this example, the context and the state get rolled together in a single type. The enum contains all the logic for state transitions and state behaviors. Notice in this example that state information is not private to the context.
enum Context {
case paused(at: TimeInterval)
case playing(start: Date)
var currentTimestamp: TimeInterval {
switch self {
case .paused(let timestamp):
return timestamp
case .playing(let startTime):
return Date().timeIntervalSince(startTime)
}
}
var image: Image {
switch self {
case .paused:
return Image("pause.png")
case .playing:
return Image("play.png")
}
}
var shouldHideControls: Bool {
switch self {
case .paused:
return true
case .playing:
return false
}
}
mutating func play() {
switch self {
case .paused(let timestamp):
let start = Date(timeInterval: -timestamp, since: Date())
self = .playing(start: start)
case .playing:
return
}
}
mutating func pause() {
switch self {
case .paused:
return
case .playing:
self = .paused(at: currentTimestamp)
}
}
}
The enum requires a lot less code, which is great! But be aware that both implementations have their trade-offs. For example, consider that exposing .playing
and .paused
from the enum could cause confusion, leading to unnecessarily checking the current state before calling play()
or pause()
.
Conclusion
Using the State pattern allows us to extract logic for state behavior and transitions making our code more testable. Using clear and consistent naming conventions, in this case State
and Context
even lets us communicate intent through code - anyone familiar with the State pattern will immediately have a mental model of what the code does. The next time you find yourself reaching for a boolean to represent your state, save yourself some time, and the humble pill that follows, and consider the State pattern instead.
Tyler is refilling his humble pill prescription at Livefront .