Shared State in Practice: isowords, Part 2

Episode #280 • May 20, 2024 • Free Episode

We conclude the series by stretching our use of the @Shared property wrapper in isowords to two more features: saved games and user defaults. In the process we’ll eliminate hundreds of lines of boilerplate and some truly gnarly code.

Previous episode
Shared State in Practice: isowords, Part 2
Next episode
FreeThis 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

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.

Sharing saved games

Let’s run the app in the simulator and start a new unlimited solo game. I will find a word, and then exit the game to go back to the main menu. We will see that now there is a little card that represents my in progress game. And if I tap it the game will launch.

Further, while in a game I can bring down this tray here to see what other in progress games I have so I can jump to them. For example, if I had a few turn based games going they would appear here. Also, this view is the exact same one that is used in the home view, so we are getting some nice reuse of it.

Here is the data type that represents the in progress games:

public struct SavedGamesState: Codable, Equatable {
  public var dailyChallengeUnlimited: InProgressGame?
  public var unlimited: InProgressGame?

  public init(
    dailyChallengeUnlimited: InProgressGame? = nil,
    unlimited: InProgressGame? = nil
  ) {
    self.dailyChallengeUnlimited = dailyChallengeUnlimited
    self.unlimited = unlimited
  }
}

We only ever need to keep track of two in progress games. We keep track of the daily challenge unlimited game because you can start it and then come back to it later. And we do the same for the unlimited solo game. But any timed game is not resumable because you only have 3 minutes to finish it anyway.

Notice that this type is Codable so that it can be saved to and loaded from disk, and also this type lives in the ClientModels module. This is the module that holds all models that are relevant to only the client app, as opposed to the server app, which is also written in Swift.

If we search for SavedGamesState in the code base we will find that it is used in a variety of features. First it’s used in the ActiveGamesView, which is the view we showed in the simulator a moment ago that appears in the home feature and game feature.

It looks like SavedGamesState is also used in the root app feature, but it’s only for loading the initial saved games from disk when the app launches:

await send(
  .savedGamesLoaded(
    Result { try await self.fileClient.loadSavedGames() }
  )
)

And for detecting changes in the saved games so that it can persist to disk:

.onChange(of: \.home.savedGames) { _, savedGames in
  Reduce { _, action in
    if case .savedGamesLoaded(.success) = action { 
      return .none
    }
    return .run { _ in
      try await self.fileClient.save(games: savedGames)
    }
  }
}

This is all work that the @Shared property wrapper takes care of for us and so hopefully we can delete all of this.

We also see mention of SavedGamesState in something called FileClientEffects.swift, which adds extensions to a FileClient dependency. This dependency exists only to interact with the file system, but ideally we could get rid of all of this and just like the @Shared property wrapper do all the heavy lifting.

There is also a mention of SavedGamesState in the home feature, where we see the monstrosity:

public var savedGames: SavedGamesState {
  didSet {
    guard
      var dailyChallengeState =
        self.destination?.dailyChallenge
    else { return }
    dailyChallengeState
      .inProgressDailyChallengeUnlimited =
        self.savedGames.dailyChallengeUnlimited
    self.destination = .dailyChallenge(dailyChallengeState)
  }
}

This declares that the Home.State wants to have a copy of savedGames, but further any change to the state will replay those changes over to the dailyChallenge feature, if it is presented. This is super brittle code, and it should not be necessary to write this kind of synchronization logic between features. Each feature should simply hold onto a @Shared saved games value and be allowed to read form it and write to it as it sees fit.

Let’s do just that.

Let’s start with the ActiveGamesView.swift file, which luckily for us exists in its own, isolated ActiveGamesFeature module. This means we can update it to use @Shared without having to worry about the rest of the application.

We’d like to update ActiveGamesState to hold onto some shared state, and like user settings we will need to provide a persistence strategy. Ideally it would look like this:

@ObservableState
public struct ActiveGamesState: Equatable {
  @Shared(.savedGames) public var savedGames
  …
}

…but that means we need to define the persistence key somewhere. We can do that in the ClientModels module, right next to the SavedGamesState type:

import Foundation 

extension PersistenceKey
where Self == PersistenceKeyDefault<
  FileStorageKey<SavedGamesState>
> {
  public static var savedGames: Self {
    PersistenceKeyDefault(
      .fileStorage(
        .documentsDirectory
        .appending(path: "saved-games.json")
      ),
      SavedGamesState()
    )
  }
}

public struct SavedGamesState: Codable, Equatable {
  …
}

And with that done the ActiveGamesFeature module is almost compiling, we just need to update this initializer since it no longer needs to take saved games state:

public init(
  turnBasedMatches: [ActiveTurnBasedMatch] = []
) {
  self.turnBasedMatches = turnBasedMatches
}

And we will need to fix the previews down below by no longer passing along the saved games…

OK, this module is compiling, but now let’s try compiling the whole app target now to see where the first error is.

We have this computed property in Home.swift:

public var activeGames: ActiveGamesState {
  get {
    .init(
      savedGames: self.savedGames,
      turnBasedMatches: self.turnBasedMatches
    )
  }
  set {
    self.savedGames = newValue.savedGames
    self.turnBasedMatches = newValue.turnBasedMatches
  }
}

…that is just passing along the saved games from the home to the active games feature.

We can now just stop passing along the saved games:

public var activeGames: ActiveGamesState {
  get {
    .init(
      turnBasedMatches: self.turnBasedMatches
    )
  }
  set {
    self.turnBasedMatches = newValue.turnBasedMatches
  }
}

And ideally this computed property would not be necessary. We should just be embedding the active games feature directly into the home and game features. The reason we did this is specifically because we needed to share the saved games state, and now that we don’t need to do that we could simplify this quite a bit.

But with that change everything is compiling, but there’s still more work to do. Let’s comment out those file client helpers for saving and loading saved games, because we no longer want to do that manually. We want the @Shared property wrapper to deal with that for us:

// import ClientModels
//
// extension FileClient {
//   public func loadSavedGames() async throws ->
//     SavedGamesState
//   {
//     try await self.load(
//       SavedGamesState.self, from: savedGamesFileName
//     )
//   }
//
//   public func save(games: SavedGamesState) async throws 
//   {
//     try await self.save(games, to: savedGamesFileName)
//   }
// }
//
// public let savedGamesFileName = "saved-games"

In fact, while we’re here, let’s just get rid of the FileClient dependency entirely…

Now when we compile we get an error in the DailyChallengeHelpers.swift file, where startDailyChallengeAsync was being handed a file client:

public func startDailyChallengeAsync(
  _ challenge: FetchTodaysDailyChallengeResponse,
  apiClient: ApiClient,
  date: @escaping () -> Date // ,
  // fileClient: FileClient
) async throws -> InProgressGame {
  …
}

And it needed a file client because it was being used to load saved games, but now we can load those games directly using @Shared:

@Shared(.savedGames) var savedGames
guard
  challenge.dailyChallenge.gameMode == .unlimited,
  let game = 
    // try? await fileClient.loadSavedGames()
    savedGames
      .dailyChallengeUnlimited

Let’s try building the app target again to see what feature module we should concentrate on next. Looks like there are some errors in the SoloView.swift file, which is in the SoloFeature module. So, let’s switch to that target, and immediately we see a use of the \.fileClient dependency that is no longer necessary:

// @Dependency(\.fileClient) var fileClient

And the only reason for this file client was to load the saved games from disk when the view appears, and so we can now get rid of that action and logic:

// case savedGamesLoaded(Result<SavedGamesState, Error>)
// case task

…

// case .savedGamesLoaded(.failure):
//   return .none
//
// case let .savedGamesLoaded(.success(savedGameState)):
//   state.inProgressGame = savedGameState.unlimited
//   return 
//
// case .task:
//   return .run { send in
//     await send(
//       .savedGamesLoaded(
//         Result {
//           try await self.fileClient.loadSavedGames()
//         }
//       )
//     )
//   }

…

// .task { await store.send(.task).finish() }

And then rather than just holding onto a small part of the saved games state we will just hold onto the whole thing:

@Reducer
public struct Solo {
  @ObservableState
  public struct State: Equatable {
    //var inProgressGame: InProgressGame?
    @Shared(.savedGames) var savedGames = SavedGamesState()
    public init() {}
    // public init(inProgressGame: InProgressGame? = nil) {
    //   self.inProgressGame = inProgressGame
    // }
  }
  …
}

And then in the view we can reach into the saved games state to grab the details of the unlimited game:

resumeText: (store.savedGames.unlimited?.currentScore)
  .flatMap {
    …
  }

And finally we can update the preview to mutate the shared saved games in order to populate the state:

struct SoloView_Previews: PreviewProvider {
  static var previews: some View {
    @Shared(.savedGames) var savedGames = SavedGamesState()
    let _ = savedGames.unlimited = update(.mock) {
      $0.moves = [
        .init(
          playedAt: Date(),
          playerIndex: nil,
          reactions: nil,
          score: 1_000,
          type: .playedWord([
            .init(
              index: .init(x: .two, y: .two, z: .two),
              side: .left
            ),
            .init(
              index: .init(x: .two, y: .two, z: .two),
              side: .right
            ),
            .init(
              index: .init(x: .two, y: .two, z: .two),
              side: .top
            ),
          ])
        )
      ]
    }

    Preview {
      NavigationView {
        SoloView(
          store: Store(initialState: Solo.State()) {
            Solo()
          }
        )
      }
    }
  }
}

And just like that the SoloFeature module is building.

Let’s again build the main app target to see where we should focus next. We see some errors in DailyChallengeView.swift, so let’s switch to the DailyChallengeFeature target. We see there is a usage of the \.fileClient dependency that we can get rid of:

// @Dependency(\.fileClient) var fileClient

And this file client is only here to pass off to the startDailyChallengeAsync helper function.

So let’s stop passing it in:

return .run { [savedGames = state.savedGames] send in
  await send(
    .startDailyChallengeResponse(
      Result {
        try await startDailyChallengeAsync(
          challenge,
          apiClient: self.apiClient,
          date: { self.now }
        )
      }
    )
  )
}

That’s all it takes to get the entire DailyChallengeFeature module building.

The game over module has the exact same error as daily challenge, so let’s remove the file client and stop trying to pass it to the helper.

If we try to build the app target again we will see some errors in the GameCore module, so let’s switch to that target. First there is a mention of \.fileClient in the Drawer.swift file, so let’s remove that:

@Reducer
public struct ActiveGamesTray {
  @Dependency(\.fileClient) var fileClient
  …
}

And the only reason for this dependency is to load the saved games when the view appears, which we no longer need:

// group.addTask {
//   await send(
//     .savedGamesLoaded(
//       Result {
//         try await self.fileClient.loadSavedGames()
//       }
//     ),
//     animation: .default
//   )
// }

Which means we can get rid of the action:

// case savedGamesLoaded(Result<SavedGamesState, Error>)

As well as wherever it was pattern matched.

There is one last mention of fileClient in this module, in GameCore.swift:

// self.fileClient = .noop

And because the dependencies are being overridden to be test dependencies, we get the in-memory file storage for fgree.

And just like that the GameCore module is compiling, which is a hefty one.

Let’s try building the main app target again to see where we still have some problems. Looks like the HomeFeature module is not building, so let’s point to that target.

There’s one error where we are trying to pass along some saved games state to the solo feature, and that is no longer necessary:

case .soloButtonTapped:
  state.destination = .solo(.init())
  return .none

And well, that’s all it takes to get the HomeFeature compiling!

We’ll go back to the full app target again to see what is left. Looks like there is an error in Demo.swift, so let’s switch to the DemoFeature module. By the way, this module is what powers the app clip that allows people play a demo game of isowords right from their browser. And this was really only possible due to our hyper modularization of the app, which allows us to build the absolute minimum of features necessary to run the app clip.

The first error we see is due to us trying to override the file client, so let’s remove that and instead override the default file storage to be in-memory:

// $0.fileClient = .noop
$0.defaultFileStorage = .inMemory

And well, that’s it! DemoFeature now compiles just fine.

The only remaining compiler errors are back in the root AppFeature module. We can start by removing any mention of the \.fileClient dependency:

// @Dependency(\.fileClient) var fileClient

And that file client was being used in order to save the data to disk when it changed, which we no longer need to do:

// .onChange(of: \.home.savedGames) { _, savedGames in
//   Reduce { _, action in
//     if case .savedGamesLoaded(.success) = action {
//       return .none
//     }
//     return .run { _ in
//       try await self.fileClient.save(games: savedGames)
//     }
//   }
// }

As well as load saved games, also no longer needed:

// await send(
//   .savedGamesLoaded(
//     Result {
//       try await self.fileClient.loadSavedGames()
//     }
//   )
// )

And we have another save here:

// return .run { [savedGames = state.home.savedGames] _ in
//   try await self.fileClient.save(games: savedGames)
// }

And that is it. The entire application is now building, and along the way we removed an entire dependency, disentangled features, removed complex synchronization logic, and allowed features to better describe what data they need to do their job.

But now we can make improvements! The Home reducer is still maintaining its own computed state for saved games in order to play changes back and forth to the daily challenge feature in that monstrosity we looked at earlier:

public var savedGames: SavedGamesState {
  didSet {
    guard
      var dailyChallengeState =
        self.destination?.dailyChallenge
    else { return }
    dailyChallengeState
      .inProgressDailyChallengeUnlimited =
        self.savedGames.dailyChallengeUnlimited
    self.destination = .dailyChallenge(dailyChallengeState)
  }
}

It would be far better for the home and daily challenge features to instead hold onto their own shared state that is automatically synchronized. So let’s swap out this computed property for shared state:

// public var savedGames: SavedGamesState {
//   didSet {
//     guard
//       var dailyChallengeState =
//         self.destination?.dailyChallenge
//     else { return }
//     dailyChallengeState
//       .inProgressDailyChallengeUnlimited =
//         self.savedGames.dailyChallengeUnlimited
//     self.destination = .dailyChallenge(dailyChallengeState)
//   }
// }
@Shared(.savedGames) var savedGames

And when we remove it from the initializer the home feature is already building.

But we need to update the daily challenge feature to use this same shared state, where it’s currently holding onto just the daily challenge saved game:

public var inProgressDailyChallengeUnlimited:
  InProgressGame?

But rather than introduce all of the saved games to this feature:

@Shared(.savedGames) var savedGames

…we can instead share just this slice of state derived from the parent:

@Shared
public var inProgressDailyChallengeUnlimited:
  InProgressGame?

Then we can update the initializer to take a shared reference from the parent:

inProgressDailyChallengeUnlimited: Shared<
  InProgressGame?
> = Shared(nil),

…

self._inProgressDailyChallengeUnlimited =
  inProgressDailyChallengeUnlimited

And if we try to build the daily challenge feature we see failures where we have some previews that now need to wrap the state in Shared.

Now the feature is compiling, but we need to home feature to pass this derived state from the projected shared saved games when it presents the daily challenge feature:

state.destination = .dailyChallenge(
  .init(
    …,
    inProgressDailyChallengeUnlimited:
      state.$savedGames.dailyChallengeUnlimited
  )
)

And this looks very similar to bindings in SwiftUI, where you use $ to get access to the projected binding, and then dot-chain to derive a whole new binding for that property.

The home module is also now building, and everything would work exactly as it did before, but we’ve been able to refactor away from really gnarly code.

And there’s more we could clean up, but let’s leave it here for now. We can run things in the simulator, though, and see the active game we started right there, which means persistence is still working, and if we drill into the game, play another word, we’ll see that it’s synchronized across all views, and if we relaunch the app we’ll see it’s persisted the updated game.

Sharing user defaults

So over and over again we see just how much we can simplify features using the new @Shared property wrapper. We have deleted hundreds of lines of code while making our features simpler and more isolated.

Stephen

There is just one last demonstration of the power of the @Shared property wrapper that we want to show off. We currently have a dependency dedicated to interacting with user defaults. It’s a bit of a pain to use, and I think we can simplify quite a bit by just using @Shared directly in our state.

Let’s take a look.

First let’s search the code base for “@Dependency(.userDefaults)” to see that there are 5 different features that are using the user defaults dependency client. For example, in the AppFeature we use user defaults to determine if the onboarding feature has already been shown to the user, and if it hasn’t, then we take them to onboarding right when the app launches:

case .appDelegate(.didFinishLaunching):
  if !self.userDefaults.hasShownFirstLaunchOnboarding {
    state.destination = .onboarding(
      Onboarding.State(presentationStyle: .firstLaunch)
    )
  }

And then a few lines down we use the client again to set the time the app was installed, which we use to determine when we should start nagging you to upgrade to the full version of the app:

if self.userDefaults.installationTime <= 0 {
  await self.userDefaults.setInstallationTime(
    self.now.timeIntervalSinceReferenceDate
  )
}

Let’s see what it takes to use the @Shared property wrapper to handle this logic.

First, these properties and methods defined on userDefaults are just little helpers that call down to the clients main interface endpoints:

public var hasShownFirstLaunchOnboarding: Bool {
  self.boolForKey(hasShownFirstLaunchOnboardingKey)
}

public var installationTime: Double {
  self.doubleForKey(installationTimeKey)
}
  
public func setInstallationTime(_ double: Double) async {
  await self.setDouble(double, installationTimeKey)
}

And then down below there are some constants for the keys:

let hasShownFirstLaunchOnboardingKey =
  "hasShownFirstLaunchOnboardingKey"
let installationTimeKey = "installationTimeKey"

So this file contains a client dependency and a lot of boilerplate code that ideally should not exist. Rather than doing all of this, let’s define some helpers on the PersistenceKey protocol so that we can use it with @Shared:

import ComposableArchitecture

extension PersistenceKey
where Self == PersistenceKeyDefault<AppStorageKey<Bool>> {
  public static var hasShownFirstLaunchOnboarding: Self {
    PersistenceKeyDefault(
      .appStorage("hasShownFirstLaunchOnboardingKey"),
      false
    )
  }
}
extension PersistenceKey
where Self == PersistenceKeyDefault<
  AppStorageKey<Double>
> {
  public static var installationTime: Self {
    PersistenceKeyDefault(
      .appStorage("installationTimeKey"),
      0.0
    )
  }
}

This lets us get rid of the \.userDefaults dependency in the AppFeature:

// @Dependency(\.userDefaults) var userDefaults

And instead we can add the user defaults fields directly to our state. In fact, since we only need read-access to the hasShownFirstLaunchOnboarding value we can even use the @SharedReader property wrapper:

@SharedReader(.hasShownFirstLaunchOnboarding)
var hasShownFirstLaunchOnboarding
@Shared(.installationTime) var installationTime

And now instead of checking the user defaults to see if the user has already been shown the onboarding flow we can just access state:

if !state.hasShownFirstLaunchOnboarding {
  state.destination = .onboarding(
    Onboarding.State(presentationStyle: .firstLaunch)
  )
}

And we can also perform the installation time logic directly in the reducer:

if state.installationTime <= 0 {
  state.installationTime =
    now.timeIntervalSinceReferenceDate
}

This all compiles, and will work exactly as it did before, but we must make one more change to use the same user defaults that were being used in the old client. In the live implementation of the user defaults client we are using a custom suite for user defaults because these defaults are shared with the app and the app clip:

UserDefaults(suiteName: "group.isowords")!

We need to do similar work to override the shared user defaults. And we can do so at the app entry point that configures all the dependencies:

} withDependencies: {
  …
  $0.defaultAppStorage =
    UserDefaults(suiteName: "group.isowords")!
}

And now we have completely removed a dependency and simplified how we interact with user defaults. In fact, if we launch the app in the simulator we are skipped right past onboarding, which shows that our migration to use shared app storage was successful!

Next let’s go over to the onboarding feature, where we are using the user defaults client:

@Dependency(\.userDefaults) var userDefaults

…in order to write to user defaults to let the system know we have shown onboarding and so we don’t need to show it again next time the app launches:

await self.userDefaults.setHasShownFirstLaunchOnboarding(
  true
)

And we read from the user defaults to figure out if we should alert the user when skipping the onboarding, but we only do so if its their first time seeing the onboarding:

case .skipButtonTapped:
  guard 
    !self.userDefaults.hasShownFirstLaunchOnboarding
  else { … }

Instead we can add some shared state to the feature’s state:

@Shared(.hasShownFirstLaunchOnboarding)
var hasShownFirstLaunchOnboarding

And get rid of the dependency:

// @Dependency(\.userDefaults) var userDefaults 

And then write to that state directly:

case .delegate(.getStarted):
  state.hasShownFirstLaunchOnboarding = true

And read from it directly:

case .skipButtonTapped:
  guard !state.hasShownFirstLaunchOnboarding else { … }

Easy easy.

The usage of the user defaults dependency in the home feature is also very easy to replace. We can get rid of the dependency:

// @Dependency(\.userDefaults) var userDefaults

And instead hold onto read-only access to the installation time:

@SharedReader(.installationTime) var installationTime

And then use that value to figure out if its time to nag the user to upgrade

let itsNagTime = Int(now - state.installationTime)
  >= self.serverConfig.config().upgradeInterstitial
    .nagBannerAfterInstallDuration

And we could keep going, but I think everyone gets the idea now. We do not need to think about user defaults as a dependency that we explicitly add to our features, and instead we can just hold onto data directly in our state, and then it just so happens that behind the scenes that data is synchronized with user defaults.

Conclusion

And that concludes our “Shared state in practice series”. We have taken two real world, complex applications, the SyncUps app we built during the 1.0 tour of the library, and isowords, our open sourced word game.

Brandon

We were able to remove numerous concepts from the code base while preserving all of its functionality, and if we finished the work we started we could probably delete hundreds of lines of code. And we didn’t even look at testing, but we have done all of this without sacrificing testability.

Stephen

It’s incredible to see, and the tools are officially out for everyone to use, so we encourage people to check it out and start a discussion on GitHub or Slack if you have any questions.

Until next time!


Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing