Harnessing the Trifecta of State: State Management with SwiftUI on iOS — Part 3/4

Christian Gaisl
6 min readSep 26, 2023

--

In my previous articles, we explored the high-level concepts of state management and the trifecta of state in mobile apps. In a follow-up article, we explored how to apply these concepts on Android with Jetpack Compose. The trifecta of state is that any screen can be broken down into state, actions, and side effects. This article will explore these concepts in action with an example screen written with SwiftUI on iOS.

Our example screen: A headline, a text field, and a button that opens the system dial dialog with the phone number.

Let’s first look at a simple implementation of our example screen. We will analyze some pain points and then determine how to improve them.

struct PhoneDialerScreenBaseline: View {
@State var username = "Christian"
@State var phoneNumber = ""

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Hello, \(username)!")

Spacer()

TextField(
"Phone number",
text: $phoneNumber
)
.keyboardType(.phonePad)

Button("Press here to dial") {
print("This is a test")
}

Spacer()
}
.padding()
}
}

Let’s focus on some things we can improve upon in this example:

Username as local state:

  • What if we want to provide a default username?
  • What if we want to load the username from a cache or from the network?

Phone number as a local state:

  • What if we want to pre-fill the phone number field?

Input validation inside of the Composable:

  • How can we test the validation?
  • What if we want to reuse it elsewhere?

Those problems stem from a common root: a lack of separation of concerns. The View is figuring out what and how to display it simultaneously. The solution is to let the View focus on how to display content and figure out the content side of things somewhere else.

The Trifecta of State

The trifecta of state. Any screen can be broken down into state, actions, and side effects.
The trifecta of state. Any screen can be broken down into state, actions, and side effects.

We can break down any screen into state, actions, and side effects. To let our View focus on the visual side of things, we need to turn it into a state consumer that merely displays whatever state it is fed and delegates actions elsewhere.

State Consumer

Let’s figure out what the state in our example would look like:

An example from one of my previous articles where we analyzed a similar screen using Jetpack Compose. The general concepts hold true for SwiftUI.

The illustration shows that the state on this View consists of the username and the phone number input.

struct PhoneDialerScreenState {
let username: String
let phoneNumber: String
}

The screen also contains two actions:

protocol PhoneDialerScreenActions {
func inputPhoneNumber(phoneNumber: String)
func onDialButtonPress()
}

We can now simplify our original screen’s code:

struct PhoneDialerScreenContent: View {
let state: PhoneDialerScreenState
let actions: PhoneDialerScreenActions

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Hello, \(state.username)!")

Spacer()

TextField(
"Phone number",
text: Binding(
get: {
state.phoneNumber
},
set: {
actions.inputPhoneNumber(phoneNumber: $0)
}
)
)
.keyboardType(.phonePad)

Button("Press here to dial") {
actions.onDialButtonPress()
}

Spacer()
}
.padding()
}
}

Our new screen can now entirely focus on the UI’s visual aspects. It is also straightforward to preview in various states and device sizes.

#Preview {
PhoneDialerScreenContent(
state: .init(
username: "username",
phoneNumber: "555-0123"
),
actions: PhoneDialerScreenActionsMock()
)
}

State Modifier

Now, let’s figure out how actually to produce the state. We need to create a class that produces and modifies our state. It also needs to be aware of the lifecycle of our screen, meaning it needs to retain the state across re-renders and have a way to clean up its resources when the screen is no longer present. The way to do this with SwiftUI is by using an ObservableObject.

The primary responsibilities of our ViewModel are producing our state and processing our actions.

class PhoneDialerScreenViewModel: ObservableObject, PhoneDialerScreenActions {
@Published private(set) var state: PhoneDialerScreenState = .init(username: "", phoneNumber: "")
let sideEffects = AsyncChannel<PhoneDialerScreenSideEffects>()

func loadUsername() {
// pretend we are loading the username from an external database
state = state.copy { $0.username = "Christian" }
}

func inputPhoneNumber(phoneNumber: String) {
state = state.copy { $0.phoneNumber = phoneNumber }
}

func onDialButtonPress() {
// TODO (we'll discuss this in the next section)
}
}

Putting everything together

Now that we have both a state consumer and a state modifier, we must somehow glue them together. In SwitfUI, this is simply a matter of instantiating our ObservableObject using the @StateObject annotation:

struct PhoneDialerScreen: View {
@StateObject var viewModel = PhoneDialerScreenViewModel()

var body: some View {
PhoneDialerScreenContent(
state: viewModel.state,
actions: viewModel
)
.task {
viewModel.loadUsername()
}
}
}

Side effects

At this point, we have a neat separation of concerns. Our View can entirely focus on the visuals, while our ViewModel can handle all the state-modifying concerns. Where things get a little bit murky is when platform-specific functionality comes into play.

In our case, we can open the system phone dialer with our button press. That’s a functionality that is provided by UIApplication. We could call our UIApplication directly in our ViewModel. However, we might want to add some logic that validates our input first. The thing we want to make testable is that when we press our call button, our ViewModel intends to open the system call dialog, depending on the current input state.

We could add a boolean property to our state indicating our intent to call a phone number. However, “openSystemDial” is hardly what one would intuitively call a state.

The solution to our problem is the concept of side effects. In addition to state, our ViewModel now also emits side effects.

enum PhoneDialerScreenSideEffects {
case dial(phoneNumber: String)
}

Here’s our updated ViewModel:


class PhoneDialerScreenViewModel: ObservableObject, PhoneDialerScreenActions {
@Published private(set) var state: PhoneDialerScreenState = .init(username: "", phoneNumber: "")
let sideEffects = AsyncChannel<PhoneDialerScreenSideEffects>()

private var tasks: [Task<Any, Error>] = []

func loadUsername() {
// pretend we are loading the username from an external database
state = state.copy { $0.username = "Christian" }
}

func inputPhoneNumber(phoneNumber: String) {
state = state.copy { $0.phoneNumber = phoneNumber }
}

func onDialButtonPress() {
tasks.append(
Task {
await sideEffects.send(.dial(phoneNumber: state.phoneNumber))
}
)
}

// Gets called when the view disappears
func cleanUp() {
tasks.forEach { $0.cancel() }
}
}

We can then consume and act upon those side effects at the location where we instantiate our ViewModel. Here’s the updated code:

struct PhoneDialerScreen: View {
@StateObject var viewModel = PhoneDialerScreenViewModel()

var body: some View {
PhoneDialerScreenContent(
state: viewModel.state,
actions: viewModel
)
.task {
viewModel.loadUsername()

for await sideEffect in viewModel.sideEffects {
switch sideEffect {
case let .dial(phoneNumber):
if let url = URL(string: "tel://\(phoneNumber)"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
}
}
.onDisappear {
viewModel.cleanUp()
}
}
}

Testing

Having our logic contained in our ViewModel makes testing a breeze. We have to instantiate our ViewModel, trigger an action, and check the resulting state and side effects:

final class PhoneDialerScreenViewModelTest: XCTestCase {
func phoneDialerViewModelTest() throws {
let viewModel = PhoneDialerScreenViewModel()

// Trigger action
viewModel.loadUsername()

// Assert state
XCTAssertEqual(viewModel.state.username, "Christian")

// Trigger action
viewModel.inputPhoneNumber(phoneNumber: "555-0123")

// Assert state
XCTAssertEqual(viewModel.state.phoneNumber, "555-0123")



// capture side effects
let sideEffectExpectation = expectation(description: "side effect should be triggered")
Task {
for await sideEffect in viewModel.sideEffects {
if case let .dial(phoneNumber) = sideEffect {
XCTAssertEqual(phoneNumber, "555-0123")
sideEffectExpectation.fulfill()
}
}
}

// Trigger action
viewModel.onDialButtonPress()

// Wait for side effect assertion
waitForExpectations(timeout: 1, handler: nil)
}
}

Full Code

The full code for this example can be found on my GitHub: https://github.com/cgaisl/StateSwiftUIArticle

Conclusion

In this article, we looked at a basic implementation of a SwiftUI View on iOS and analyzed some common pain points. We harnessed the trifecta of state, explored in one of my previous articles, to address some of those points, which lead to a more maintainable and testable code. I hope this gave us a more concrete understanding of state management and the trifecta of state. Remember that the general concepts are platform agnostic and can easily be adapted for different platforms. One of my accompanying articles explores a similar example on Android using Jetpack Compose.

In the next article in this series, we will explore the possibility of sharing our State Modifiers across platforms using Kotlin Multiplatform.

Enjoyed this article? Feel free to give me a clap 👏, write a comment, and follow me here on Medium to catch all my latest articles. Thanks 👋

--

--