Shared State in Practice: isowords, Part 1

Episode #279 • May 13, 2024 • Free Episode

Let’s apply the Composable Architecture’s new state sharing tools to something even more real world: our open source word game, isowords. It currently models its user settings as a cumbersome dependency that requires a lot of code to keep features in sync when settings change. We should be able to greatly simplify things with the @Shared property wrapper.

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

Stephen: We have now finished updating the SyncUps app to take full advantage of the new shared state tools offered by the Composable Architecture. We are even using some of the more advanced features of the tools.

At the root of the application we are using the @Shared property wrapper with a fileStorage persistence strategy so that we can automatically persist any changes made to the app’s data to disk. Further we derive small bits of shared state from the collection of sync ups to hand off to child views, much like you would with bindings in vanilla SwiftUI.

Brandon: This allows child features to make changes to the state that are immediately played back to the parent, and further the parent is free to make any changes to the state and it will also be reflected in the child. And further this allowed us to delete a few delegate actions that only served to communicate from child to parent feature in order to synchronize state. That was needlessly complicated, and so we have simplified our features and boosted their encapsulation and isolation along the way! Oh, and also everything is still 100% testable, and even exhaustively testable.

Stephen: So this was great to see, but also even the SyncUps app is a bit simple. It’s a great demo application to learn the basics of building an application, but it would be great to see how the new shared state tools could be used in a real life application.

Brandon: And luckily we have a very large, complex application to show this off, and it’s even open source. A few years ago we released a word game called isowords, and from the very beginning it was open source. It is built entirely in the Composable Architecture and SwiftUI, except for a few small views that are built in SceneKit.

At the beginning of our series on shared state we used the isowords project to show off a few places where we have to jump through hoops to share state, in particular with the user settings in the app. This is a simple data type that needs to be read from and written to in many places in the app, and the way we accomplished this was with a dependency. But it was quite messy.

Let’s see how things simplify now using the new @Shared property wrapper.

The problem with user settings

I’ve got the isowords project open right here, and let’s quickly remember how user settings are currently handled and why it is not ideal. There is a module in this project called UserSettingsClient that defines a single type that holds onto all of the settings that can be tweaked by the user:

public struct UserSettings: Codable, Equatable {
  public var appIcon: AppIcon?
  public var colorScheme: ColorScheme
  public var enableGyroMotion: Bool
  public var enableHaptics: Bool
  public var enableNotifications: Bool
  public var enableReducedAnimation: Bool
  public var musicVolume: Float
  public var sendDailyChallengeReminder: Bool
  public var sendDailyChallengeSummary: Bool
  public var soundEffectsVolume: Float
  
  …
}

And note that this type is Codable because we want to persist this data to disk.

And the module also provides a dependency interface for something that is capable of getting the current settings, updating the current settings with a new value, as well as producing a stream of updates so that one can subscribe if they want to get the freshest data:

@dynamicMemberLookup
public struct UserSettingsClient {
  public var get: @Sendable () -> UserSettings
  public var set: @Sendable (UserSettings) async -> Void
  public var stream:
    @Sendable () -> AsyncStream<UserSettings>
  
  …
}

And then all kinds of bells and whistles are added to this dependency client in order to make it a bit more ergonomic.

For example, dynamic member look up is provided so that you don’t have to go through the get() endpoint just to pluck a single setting off the struct:

public subscript<Value>(
  dynamicMember keyPath: KeyPath<UserSettings, Value>
) -> Value {
  self.get()[keyPath: keyPath]
}

And a modify method is provide that does a get + transform + set under the hood so that at the call site you can just perform a simple mutation:

public func modify(
  _ operation: (inout UserSettings) -> Void
) async {
  var userSettings = self.get()
  operation(&userSettings)
  await self.set(userSettings)
}

Then we conform to the DependencyKey protocol by providing a liveValue, which does the real work of loading the data from disk and saving data to disk when the settings change:

extension UserSettingsClient: DependencyKey {
  public static var liveValue: UserSettingsClient {
    …
  }
}

A mock is also provided that is helpful for testing:

public static func mock(
  initialUserSettings: UserSettings = UserSettings()
) -> Self {
  …
}

This implementation doesn’t actually interact with the file system at all, and instead stores the settings right in memory.

And then finally a computed property is added to DependencyValues:

extension DependencyValues {
  public var userSettings: UserSettingsClient {
    …
  }
}

…which is what allows anyone to get access to this dependency by using the @Dependency property wrapper with a key path:

@Dependency(\.userSettings) var userSettings

Honestly, this is quite a bit of work for something so simple. At its core all we have is some simple settings data type that we want to be available from any part of the application, and we want the state automatically persisted to the file system.

And if we ever have another small piece of data we want to persist to the file system we have to repeat all of this again. We need to define the dependency interface, define the helpers to make it ergonomic, conform to DependencyKey and provide a liveValue and testValue, and then also don’t forget to provide a computed property on DependencyValues.

And even if we took the time to abstract this pattern into some kind of generic client of all clients, we would still end up with a solution that pales in comparison with our brand new shared state tools.

But things get even worse when you see how one uses this dependency client. If we do a search for “@Dependency(.userSettings)” in the code base we will see it is used 8 times across 5 files. Some of these are pretty straightforward usages of the dependency.

For example, in the AppDelegate reducer, which is the reducer that sits at the very root of the application, we have a dependence on userSettings:

@Reducer
public struct AppDelegateReducer {
  …
  @Dependency(\.userSettings) var userSettings
  …
}

…because when the app first launches we use the settings to restore some values for the audio player and the user interface style:

group.addTask {
  await self.audioPlayer.setGlobalVolumeForSoundEffects(
    userSettings.soundEffectsVolume
  )
  await self.audioPlayer.setGlobalVolumeForMusic(
    self.audioPlayer.secondaryAudioShouldBeSilencedHint()
      ? 0
      : userSettings.musicVolume
  )
  await self.setUserInterfaceStyle(
    userSettings.colorScheme.userInterfaceStyle
  )
}

That is very simple.

The same is true of the Onboarding reducer:

@Reducer
public struct Onboarding {
  …
  @Dependency(\.userSettings) var userSettings
  …
}

…which only needs the user settings dependency so that it knows when to enable or disable haptics on the game:

Game()
  .haptics(
    isEnabled: { _ in self.userSettings.enableHaptics },
    triggerOnChangeOf: \.selectedWord
  )

This too is quite simple, and it works so well because we are only accessing the dependency in the reducer.

Things get trickier when we need to access these user settings in the view. For example, in the Game reducer we use the userSettings dependency to first initialize Game.State with some values from the settings:

public init(
  …
) {
  @Dependency(\.userSettings) var userSettings
  …
  self.enableGyroMotion = userSettings.enableGyroMotion
  …
  self.isAnimationReduced =
    userSettings.enableReducedAnimation
  …
}

Then we subscribe to changes to userSettings and send an action when it does change:

group.addTask {
  for await userSettings in self.userSettings.stream() {
    await send(.userSettingsUpdated(userSettings))
  }
}

And we do that so that we can store the new settings value in Game.State when settings changes:

case let .userSettingsUpdated(userSettings):
  state.enableGyroMotion = userSettings.enableGyroMotion
  state.isAnimationReduced =
    userSettings.enableReducedAnimation
  return .none

And the whole reason we are doing this complex, multi-step process is because we need user settings in the view. For example, in the GameView we use the isAnimationReduced boolean to not perform a sliding animation on the submit button:

.transition(
  store.isAnimationReduced
    ? .opacity
    : .asymmetric(
        insertion: .offset(y: 50), removal: .offset(y: 50)
      )
      .combined(with: .opacity)
)

And in the CubeSceneView we use enableGyroMotion to determine if we should start or stop the motion manager:

if isOnLowPowerMode || !enableGyroMotion {
  self.stopMotionManager()
} else {
  self.startMotionManager()
}

And because user settings are trapped inside a dependencies we have to repeat this story every time we need settings in the view layer.

Using @Shared for user settings

So clearly the way we are handling settings right now is not ideal. And we didn’t even show what things look like in tests. We have to do a bunch of awkward juggling of dependencies for any test that involves settings when all we want to do is just make simple assertions on how state changes.

Stephen: Well, luckily for us we now have the proper tool to handle something like user settings. We would like the settings data to live directly in our features’ state, and we would like that if we hold onto settings in multiple states they are all kept in sync, and further we would like any changes to settings to be automatically persisted to disk.

All of this can be accomplished with the new @Shared property wrapper, so let’s give it a shot.

Let’s start with the UserSettingsClient module to get it into the shape we want to be able to work with in the app, and then we will slowly make use of it all throughout the app.

Luckily the app is heavily modularized which means we can choose the smallest module to work on, get it into compiling order, and then slowly make our way back to the root to get everything compiling.

So, let’s select the UserSettingsClient target, which we can do by using the keyboard shortcut ctrl+0 and then typing into the popover to filter the list. And first and foremost I think the entire concept of the UserSettingsClient dependency should go away. I am going to completely comment out that file…

With that done the module still compiles because no other part of the module was making use of these types.

Instead of using the UserSettingsClient we would love if anyone that wants access to settings could just declare it via the @Shared property wrapper:

struct State {
  @Shared var userSettings
}

But also we want the initial settings to be loaded from disk and we want any changes to settings to be automatically saved to disk, so we will use the .fileStorage strategy:

struct State {
  @Shared(.fileStorage(<#URL#>)) var userSettings
}

The URL is already defined as a static so that it is easily accessible:

struct State {
  @Shared(.fileStorage(.userSettings)) var userSettings
}

And then to use a persistence strategy with @Shared we need to supply a default, which is what will be used in the case that there is no pre-existing settings on disk:

struct State {
  @Shared(.fileStorage(.userSettings))
  var userSettings = UserSettings()
}

This is basically how @AppStorage works in SwiftUI too:

@AppStorage("count") var count = 0

You must supply a default, like 0 here, which will be used when there is no user default corresponding to the “count” key.

OK, with that everything is compiling, and we technically could use @Shared exactly as it is described here, but we can improve this. We can define a single static on the PersistenceKey protocol that describes not only the type of persistence we are performing, which is fileStorage, but also the type of data we are persisting:

extension PersistenceKey
where Self == FileStorageKey<UserSettings> {
  public static var userSettings: Self {
    fileStorage(.userSettings)
  }
}

We now get to shorten our usage of @Shared to just this:

struct State {
  @Shared(.userSettings) var userSettings = UserSettings()
}

And even better, if we ever accidentally gave a default value that did not match UserSettings, we will get an error:

struct State {
  @Shared(.userSettings) var userSettings = false  // 🛑
}

Which is not the case for @AppStorage.

But we can take things even further by eliminating the need to specify a default at all. If we wrap our static with a persistence key default:

extension PersistenceKey
where Self == PersistenceKeyDefault<
  FileStorageKey<UserSettings>
> {
  public static var userSettings: Self {
    PersistenceKeyDefault(
      fileStorage(.userSettings),
      UserSettings
    )
  }
}

…then we can make the call sites super succinct:

@Shared(.userSettings) var userSettings

OK, this module is compiling and everything is looking pretty great. But we haven’t used it yet, and I bet there are going to be a lot of compilation errors in the project.

Just to take a peek at how bad of a state the project is in let’s select the full isowords app target and build…

Huh, Xcode is telling us that there are only 2 errors, but that definitely is not right. I mean we had 8 usages of the \.userSettings dependency in our code base, so at the very least there should be 8 errors.

Well, the reason this is 2 is because the CubePreview module build failed, and that prevented Xcode from even getting to the other modules. And so we would need to fix these errors and then see what other errors pop up in Xcode has it is able to compile more and more files.

But then also I’m sure we are all well aware of the fact that Xcode doesn’t do the greatest job at showing the correct errors in a project. Sometimes it shows phantom errors of things that have already been fixed, and other times it doesn’t show errors on lines that are definitely broken.

For this reason it is best to try to build the small module as possible so that Xcode has a better chance at giving you good feedback. And so we can going to use the keyboard shortcut ctrl+0 and select the CubePreview module.

We can fix the first few errors by just getting rid of any mention of the \.userSettings dependency:

// @Dependency(\.userSettings) var userSettings

One of those uses is in the initializer of CubePreview.State so that it can populate some fields from the user settings. But instead of doing that, let’s now hold onto shared user settings right in the state:

@ObservableState
public struct State: Equatable {
  …
  // var enableGyroMotion: Bool
  // var isAnimationReduced: Bool
  …
  @Shared(.userSettings) var userSettings
}

And then there’s no need to assign these fields in the initializer:

// @Dependency(\.userSettings) var userSettings
// self.enableGyroMotion = userSettings.enableGyroMotion
// self.isAnimationReduced =
//   userSettings.enableReducedAnimation

Next we have an error when constructing CubeSceneView.ViewState because we are passing along the gyro motion setting, but now we can take that from self.userSettings:

return CubeSceneView.ViewState(
  …
  enableGyroMotion: self.userSettings.enableGyroMotion,
  …
)

Next we have an error down where we are invoking a haptics helper:

.haptics(
  isEnabled: { _ in self.userSettings.enableHaptics },
  triggerOnChangeOf: \.selectedCubeFaces
)

We no longer have access to self.userSettings as a dependency, and instead the user settings lives right inside state:

.haptics(
  isEnabled: \.userSettings.enableHaptics,
  triggerOnChangeOf: \.selectedCubeFaces
)

Next down in the view we are trying to reduce background animation when the setting is set to true:

if !store.isAnimationReduced {
  …
}

But now we can get this boolean from the userSettings in state:

if !store.userSettings.enableReducedAnimation {
  …
}

And with that the CubePreview is compiling! We of course still have a ways to go, but it’s nice to get a win in a small, isolated module without having to worry about the entire app.

If we trying to build the entire app we will get some compilation errors point to the SettingsFeature module, so let’s put our effort into that module next. We will select it from the target list, and the first error we see is down in the preview where we are trying to start up the view with a specific value of settings in place:

SoundsSettingsView(
  store: Store(initialState: Settings.State()) {
    Settings()
  } withDependencies: {
    $0.userSettings = .mock(
      initialUserSettings: UserSettings(
        musicVolume: 0.5,
        soundEffectsVolume: 0.5
      )
    )
  }
)

There is a simpler way to do this now rather than going through the dependency system. We can simply declare a little local @Shared value, mutate it, and then construct the view like normal:

static var previews: some View {
  @Shared(.userSettings) var userSettings
  userSettings.musicVolume = 0.5
  userSettings.soundEffectsVolume = 0.5

  return Preview {
    NavigationView {
      SoundsSettingsView(
        store: Store(initialState: Settings.State()) {
          Settings()
        }
      )
    }
  }
}

The next error we see is us using the \.userSettings dependency, so let’s remove that (in both places):

// @Dependency(\.userSettings) var userSettings

And now rather than holding onto a copy of userSettings in the state we will hold onto the shared state:

@Shared(.userSettings) public var userSettings

…which means we no longer need to assign in the initializer:

// self.userSettings = userSettings.get()

The next error we see is down in the body of the reducer where we save user settings when it changes. All of the persistence logic is handled for us automatically by the @Shared property wrapper, including throttling so that we don’t thrash the file system, and so we can just assume that all works perfectly and therefore do not need any of this:

// .onChange(of: \.userSettings) { _, userSettings in
//   Reduce { _, _ in
//     enum CancelID { case saveDebounce }
//
//     return .run { _ in
//       await self.userSettings.set(userSettings)
//     }
//     .debounce(
//       id: CancelID.saveDebounce,
//       for: .seconds(0.5),
//       scheduler: self.mainQueue
//     )
//   }
// }

That’s all it takes, and now the SettingsFeature module is building.

Let’s try building the main app target again to see where there are errors left to fix…

It looks like the GameCore module is next, so let’s switch to that target. First we see a few usages of the \.userSettings dependency, so let’s get rid of them:

// @Dependency(\.userSettings) var userSettings

And rather than holding onto enableGyroMotion and isAnimationReduced directly in state, let’s just hold onto the shared user settings:

// public var enableGyroMotion: Bool
// public var isAnimationReduced: Bool
@Shared(.userSettings) public var userSettings

And then we have a place where we looping over the user settings changes so that we can play them back to the reducer, and that is no longer needed:

// group.addTask {
//   for await userSettings in self.userSettings.stream() {
//     await send(.userSettingsUpdated(userSettings))
//   }
// }

And in fact we can get rid of that action:

// case userSettingsUpdated(UserSettings)

As well as the logic for it:

// case let .userSettingsUpdated(userSettings):
//   state.enableGyroMotion = userSettings
//     .enableGyroMotion
//   state.isAnimationReduced =
//     userSettings.enableReducedAnimation
//   return .none

Next there is a place in the view we access enableGyroMotion, which we can now get straight from the userSettings in state:

enableGyroMotion: game.userSettings.enableGyroMotion,

Next we have an error of trying to access isAnimationReduced directly on the store, but now that lives in userSettings:

store.userSettings.enableReducedAnimation

And we have a few other spots we were destructuring the userSettingsUpdated action that now go away:

// .userSettingsUpdated,

Next we have a compilation error in the view, and sadly Swift does not give us a good error message. It is a very complex view, so if we were to break it down into smaller parts we would probably get a better message.

But it seems very likely that the error is due accessing either enableGyroMotion or isAnimationReduced since those fields no longer exist on the store. And indeed there are two places we are accessing isAnimationReduced that now need to go through userSettings:

store.userSettings.enableReducedAnimation

And with that, amazingly the entire GameCore module is compiling, which is one of the most complex modules in the app.

Let’s again back up to the main app target to see what’s next…

We see some errors in the OnboardingFeature module, so let’s target that module for now. The first error is a reference to the \.userSettings dependency, and so we will remove it:

// @Dependency(\.userSettings) var userSettings

The next error is down in the reducer body where we use the haptics operator, which can now access enableHaptics from the userSettings on state rather than the dependency:

.haptics(
  isEnabled: \.userSettings.enableHaptics,
  triggerOnChangeOf: \.selectedWord
)

And just like that the OnboardingFeature is now compiling.

If we build the main app target again we will see that the next error is in the AppFeature, which means we are getting really close to a fully compiling app. Let’s select the AppFeature module to build it in isolation, and the first thing we can do is remove our use of the \.userSettings dependency:

// @Dependency(\.userSettings) var userSettings

Then we have an error in the effect where we set the initial values of the audio player and user interface based on user settings.

Well, we can get access to the shared user settings directly in this effect in order to set the initial values on those other dependencies:

group.addTask {
  @Shared(.userSettings) var userSettings
  …
}

And just like that the AppFeature module is compiling, and in fact now the entire app target is compiling. And everything works exactly as it did before. We can run it in the simulator to see that we can still tweak settings, from both the home screen and game screen, and the data is persisted across app launches.

Next time: Using @Shared for saved games

It is absolutely incredible how much things simplified in the app by using the @Shared property wrapper for user settings. We can just drop the settings directly into any feature’s state and we can be rest assured that it will always hold the newest value and the view will properly update when the settings change. It’s really amazing to see.

Brandon: But there is another spot in isowords that I think we can make use of the new @Shared property wrapper. We save in progress games you have to disk so that you can resume them at a later time. This state needs to be accessible from a few places in the app, including the home feature and the game feature, and further any changes to be persisted to the disk.

Sounds like the perfect job for the @Shared property wrapper, so let’s give it a shot…next time!

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in