Shared State in Practice: SyncUps, Part 1

Episode #277 • Apr 29, 2024 • Free Episode

In our last series we developed a wonderful way to share state between features in the Composable Architecture, and even persist it, all without sacrificing testability, but we also didn’t get to show the (just now released) tools being used in real world applications, so let’s do just that, starting with SyncUps.

Previous episode
Shared State in Practice: SyncUps, Part 1
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

Brandon

For the past few weeks we have gone deep into the techniques of sharing state in the Composable Architecture. When we say “shared state” we mean something very specific. We mean:

  • A piece of state that can be easily accessed and mutated from multiple features such that when a feature mutates that state, every other feature instantly sees those changes.

  • And often, such shared state is ubiquitous throughout the app and needs to be persisted. That is, many features need access to the state, and we do not want to have to explicitly pass it through multiple layers just to get it to a feature that needs it, and after relaunching the app the state should be restored to its last value.

And at the end of those weeks of deep study we came out the other side with a really wonderful solution. Sharing state in the Composable Architecture simply means holding that state in a reference type via the @Shared property wrapper, and persistence is handled by a simple strategy that is passed to the property wrapper.

Stephen

That simple idea led to a very powerful and ergonomic way of sharing state, and it was only possible thanks to the wonderful observation tools that Swift 5.9 introduced. And also thanks to our back-port of those observation tools we can use the shared state tools immediately, no matter what version of iOS we are targeting. And we even put in a bit of extra work to make sure that shared state could still be exhaustively tested, even though typically reference types are very difficult to test.

So, all of that was really great, but we also didn’t get to show the tools being used in a real world application. We used a simple case study from the repo to get our hands dirty and figure out what the tools should look like, but let’s now see a much better example.

Brandon

We are going to take the SyncUps demo application from the repo, which is something we built from scratch in our tour series of episodes when the 1.0 of the library was released, and we are going to update it to use shared state where appropriate. Currently the SyncUps app does some annoying manual work to synchronize state between multiple features, and ideally shared state should make that much more natural to accomplish.

So, let’s see what problems we currently have with sharing state in the SyncUps app, and then let’s fix it.

SyncUps without sharing

First, for those that may not be familiar, SyncUps is a moderately complex app that we built in the Composable Architecture in order to demonstrate many real world problems that one encounters when building apps. It’s a port of a code sample that Apple released a few years ago called “Scrumdinger”.

When you launch the application you have the option of adding a new sync up, which entails setting a title, duration, color theme, and list of attendees. Then you can drill down to that sync-up and do a few things, such as edit it, delete it, see past recorded meetings, of which there are none right now, or start a new meeting.

Doing that causes the app to ask you for recording permissions so that it can transcribe audio for the meeting. We will deny this because the speech recognition APIs don’t work very well in the simulator.

And now we are recording a meeting. There is a timer going and some UI that tells us which speaker is currently allowed to give their update. We could either wait for the time to count down, or we can choose to end the meeting early.

We’ll do that, and we’ll save the meeting. Now we are popped back to the detail screen, and a new meeting has been inserted into the list. There is also persistence in the app so that if I force quit the app and relaunch it, we will see the sync up is still in the list with the meeting we just recorded.

So, this is a decently complex app, and it involves some interesting navigation flows, as well as complex dependencies such as a speech recognizer, timer, and file storage. We built this app from scratch during our tour series of episodes after version 1.0 of the library was released. And there is only one part of the code base that we aren’t really happy with, and that is the manner in which we share state between the various features.

To see this I’ve got the Composable Architecture repo open right now, and it is pointed to the most recent release of the library, which includes all the shared tools we have built over the past many weeks. I also have the SyncUps app code open right here, but this is the version of the code from before the shared state tools were integrated. We will now update this app to make use of the new tools, and see what all can be simplified.

But first, let’s see what’s wrong with the code. The first indication that something isn’t quite right in the SyncUps app can be seen right in the root-most feature, the AppFeature. In this reducer we handle a delegate action from the detail feature when it is presented in the stack:

case let .path(
  .element(id, .detail(.delegate(delegateAction)))
):

That alone is not so bad, and in fact is quite reasonable. Delegate actions are a great way for a child feature to tell the parent that something has happened so that the parent can react to it.

For example, the detail screen needs to tell the parent when to start a meeting by appending some RecordMeeting.State to the navigation path:

case .startMeeting:
  state.path.append(
    .record(
      RecordMeeting.State(syncUp: detailState.syncUp)
    )
  )
  return .none

This is necessary because the detail screen doesn’t even have access to the navigation path, which is great for decoupling and isolation, but at the end of the day it still wants to navigate somewhere. And further, it performs some logic around navigation. It checks if speech recognition access has been previously granted, and if it not it requests access before pushing to the next feature:

case .startMeetingButtonTapped:
  switch self.authorizationStatus() {
  case .notDetermined, .authorized:
    return .send(.delegate(.startMeeting))

And so that logic can be encapsulated in the detail feature, and then when it’s ready it will tell the parent it is time to navigate.

That’s a great use of delegate actions. It keeps the detail feature de-coupled from the record meeting feature so that they don’t know about it each other, but they are still allowed to communicate with one another thanks to the parent feature providing the integration glue.

However, right above that we see something strange:

case let .syncUpUpdated(syncUp):
  state.syncUpsList.syncUps[id: syncUp.id] = syncUp
  return .none

The detail screen must also tell the parent when the sync up was updated so that it can replay those changes back to the list of sync ups.

And the main reason this is necessary is because the Composable Architecture strongly prefers that value types be used to model domains, but that comes with pros and cons. The pros are that they are infinitely more understandable and testable than references, but the con is that they cannot be shared. So we must perform synchronization manually, and it can be really tricky.

For example, if we hop over to the detail feature we will see what it takes to send the syncUpUpdated delegate action:

.onChange(of: \.syncUp) { oldValue, newValue in
  Reduce { state, action in
    .send(.delegate(.syncUpUpdated(newValue)))
  }
}

We have to listen for any change the data held inside SyncUpDetail.State and then send those changes back to the parent through the delegate action so that it can play those changes back to the list.

It just seems very convoluted.

Right above syncUpUpdated is the deleteSyncUp delegate action, which is another point of synchronization logic in which the detail needs to tell the parent to delete a sync-up. Wouldn’t it be far nicer if the detail screen could totally encapsulate this logic?

There’s another example of this synchronization logic being performed in the app, and it’s when the record feature needs to tell the parent feature that the meeting has ended and to save the transcript. That is done here:

case let .path(
  .element(_, .record(.delegate(delegateAction)))
):
  switch delegateAction {
  case let .save(transcript: transcript):
    guard let id = state.path.ids.dropLast().last
    else {
      XCTFail(
        """
        Record meeting is the only element in the stack. \
        A detail feature should precede it.
        """
      )
      return .none
    }

    state.path[id: id]?.detail?.syncUp.meetings.insert(
      Meeting(
        id: Meeting.ID(self.uuid()),
        date: self.now,
        transcript: transcript
      ),
      at: 0
    )
    guard let syncUp = state.path[id: id]?.detail?.syncUp
    else { return .none }
    state.syncUpsList.syncUps[id: syncUp.id] = syncUp
    return .none
  }

When the record feature tells the parent feature to save, it needs to do some complex work to find the detail feature on the stack, and then subscript into the path to update the sync up. And then further update the sync up over in the sync ups list feature.

This is very subtle code and difficult to understand. I wouldn’t be surprised if I become really confused looking at this code 6 months from now.

But the one good thing about this code and the other snippets we looked at, is that they are 100% testable. So even though this code is a bit confusing and may be hard to understand, we are able to write a full test suite that describes exactly how we exact the state to change. This means if 6 months from now we come to this code, don’t understand it, and think we can refactor it to something simpler, we will have a test suite to let us know if we got everything right.

In fact, let’s run it right now just to make sure everything is passing…

And it is.

And just to make sure we’ve got coverage on these things, let’s introduce a small bug to this code:

-guard let id = state.path.ids.dropLast().last
+guard let id = state.path.ids.last

Run the suite again…

And now we get some failures.

So, that is nice that we can get test coverage on all of this synchronization logic, but also sometimes the testing can be a little annoying. Because we are employing tricks to synchronize state between features, we also have to assert on all of that synchronization behavior. If we go to the AppFeature tests, and in particular testDetailEdit, we will see that we explicitly assert on the synchronization logic here:

await store.receive(
  \.path[id:0].detail.delegate.syncUpUpdated
) {
  $0.syncUpsList.syncUps[0].title = "Blob"
}

On the one hand it’s nice that we can assert on the behavior, but on the other hand it seems pretty noisy. We need to receive an explicit action just so that we can prove that indeed the list of sync ups was updated with the new data.

Sync-ups file storage

So, that was a quick tour of the SyncUps app, both from the perspective of what functionality the app has, as well as what it is about the code that we aren’t exactly happy with.

Stephen

So, let’s start leveraging the new shared state tools of the library to make things better. In conjunction with the release of this episode we have officially released version 1.10 of the library, which includes all of the shared state tools. Those tools are very similar to what we built in the last series of episodes, so if you are curious about what all went into designing those tools, we highly encourage you to watch those episodes.

But for now we are just going to use the official version of the tools to refactor the SyncUps app.

Let’s get started.

So, let’s start using the new shared tools to see how this can be massively simplified.

First, where exactly should we install the shared state? We can start with the list of sync ups. If that data was shared somehow, that would mean the detail feature could take a piece of that share data, and any changes it made would be instantly visible to the parent feature. No need to use delegate actions to communicate changes.

If we look at the state of the SyncUpsList feature we will see the following:

@Reducer
struct SyncUpsList {
  @ObservableState
  struct State: Equatable {
    @Presents var destination: Destination.State?
    var syncUps: IdentifiedArrayOf<SyncUp> = []
    …
  }
  …
}

It holds onto an identified array of SyncUps.

We’d like this state to be shared so that any changes to it could be instantly seen by every part of the application:

@Shared var syncUps: IdentifiedArrayOf<SyncUp> = []

But we can take things one step further.

There is also a bunch of work being done in the app to persist these sync ups to disk and to load the data from disk. For example, just below in the initializer we have this:

init(destination: Destination.State? = nil) {
  self.destination = destination

  do {
    @Dependency(\.dataManager.load) var load
    self.syncUps = try JSONDecoder().decode(
      IdentifiedArray.self, from: load(.syncUps)
    )
  } catch is DecodingError {
    self.destination = .alert(.dataFailedToLoad)
  } catch {
  }
}

When this feature’s state is first initialized we try loading the data from disk using a \.dataManager dependency.

And then back in the AppFeature reducer we compose in a little helper reducer at the very end of the body in order to save the sync ups to disk. We even bake in some debouncing logic:

Reduce { state, action in
  return .run { [syncUps = state.syncUpsList.syncUps] _ in
    try await withTaskCancellation(
      id: CancelID.saveDebounce, cancelInFlight: true
    ) {
      try await self.clock.sleep(for: .seconds(1))
      try await self.saveData(
        JSONEncoder().encode(syncUps), .syncUps
      )
    }
  } catch: { _, _ in
  }
}

This is sounding like a lot of the work that the file storage persistence strategy provides for us out of the box.

In fact, there is even a small problem with this code right now. It saves after a debounce, but it does not save upon app resignation. That means if the user makes a change to the data, and quickly force quits the app, their changes will be lost. And so the file storage persistence strategy will even improve upon all of this code.

So, let’s go back to the SyncUpsList feature and go straight for the fileStorage persistence strategy:

@Shared(.fileStorage(.syncUps))
var syncUps: IdentifiedArrayOf<SyncUp> = []

We can even reuse the .syncUps URL helper we made for the old-style persistence.

First of all, even with that change the entire application is still compiling. This shows that you will be able to slowly layer on persistence in your current application without needing to overhaul everything.

And also now that we are leveraging the library’s storage tools we can get rid of the initializer entirely, and the SyncUpsList.State becomes super simple:

@ObservableState
struct State: Equatable {
  @Presents var destination: Destination.State?
  @Shared(.fileStorage(.syncUps))
  var syncUps: IdentifiedArrayOf<SyncUp> = []

  // init(destination: Destination.State? = nil) {
  //   self.destination = destination
  //
  //   do {
  //     @Dependency(\.dataManager.load) var load
  //     self.syncUps = try JSONDecoder().decode(
  //       IdentifiedArray.self, from: load(.syncUps)
  //     )
  //   } catch is DecodingError {
  //     self.destination = .alert(.dataFailedToLoad)
  //   } catch {
  //   }
  // }
}

Let’s also get rid of that saving logic over in the AppFeature:

// Reduce { state, action in
//   return .run {
//     [syncUps = state.syncUpsList.syncUps] _ in
//
//     try await withTaskCancellation(
//       id: CancelID.saveDebounce, cancelInFlight: true
//     ) {
//       try await self.clock.sleep(for: .seconds(1))
//       try await self.saveData(
//         JSONEncoder().encode(syncUps), .syncUps
//       )
//     }
//   } catch: { _, _ in
//   }
// }

And amazingly the app is still compiling, and it even works exactly as it did before. Even persistence! We can add a new sync up, relaunch the app, and we see the data is persisted.

We can even delete more code. For one thing, we are no longer making use of alerts in the SyncUpsList feature anymore, so let’s delete all of that code. That includes the alert destination:

// case alert(AlertState<Alert>)
//
// enum Alert {
//   case confirmLoadMockData
// }

No need to handle that action in the reducer:

// case .destination(
//   .presented(.alert(.confirmLoadMockData))
// ):
//   state.syncUps = [
//     .mock,
//     .designMock,
//     .engineeringMock,
//   ]
//   return .none

Nor do we need to show an alert in the view:

// .alert(
//   $store.scope(
//     state: \.destination?.alert,
//     action: \.destination.alert
//   )
// )

And we can get rid of the AlertState helper for constructing the alert:

// extension AlertState
// where Action == SyncUpsList.Destination.Alert {
//   static let dataFailedToLoad = Self {
//     TextState("Data failed to load")
//   } actions: {
//     ButtonState(
//       action: .send(
//         .confirmLoadMockData,
//         animation: .default
//       )
//     ) {
//       TextState("Yes")
//     }
//     ButtonState(role: .cancel) {
//       TextState("No")
//     }
//   } message: {
//     TextState(
//       """
//       Unfortunately your past data failed to load. \
//       Would you like to load some mock data to play \
//       around with?
//       """
//     )
//   }
// }

We can even get rid of the \.dataManager dependency in the AppFeature:

// @Dependency(\.dataManager.save) var saveData

And we can even delete the entire DataManager.swift file…

That creates a few more compiler errors showing things that need to be tweaked or deleted.

For example, in the entry point of the app we were using a mock data manager when running in UI tests:

} withDependencies: {
  if ProcessInfo.processInfo.environment["UITesting"]
    == "true"
  {
    $0.dataManager = .mock()
  }
}

We do this so that during UI tests we do not save any data to the disk, which would cause that data to bleed over from test to test causing mysterious test failures.

The equivalent version of this in the @Shared(.fileStorage) world is to override the defaultFileStorage dependency, which is what the fileStorage persistence strategy uses under the hood to save and load data from disk. The library comes with an in-memory file storage that simply holds data in memory so that it will be cleared between tests:

} withDependencies: {
  if ProcessInfo.processInfo.environment["UITesting"]
    == "true"
  {
    $0.defaultFileStorage = .inMemory
  }
}

This value basically does exactly what the .mock() data manager did.

And then in previews instead of seeding the initial data of a feature through the data manager, we will override the shared state directly in the preview:

#Preview {
  @Shared(.fileStorage(.syncUps))
  var syncUps: IdentifiedArray = [
    SyncUp.mock,
    .designMock,
    .engineeringMock,
  ]
  return NavigationStack {
    SyncUpsListView(
      store: Store(initialState: SyncUpsList.State()) {
        SyncUpsList()
      } // withDependencies: {
      //   $0.dataManager.load = { @Sendable _ in
      //     try JSONEncoder().encode([
      //       SyncUp.mock,
      //       .designMock,
      //       .engineeringMock,
      //     ])
      //   }
      // }
    )
  }
} 

And the other preview that exercises an unhappy path of not being able to load data is no longer relevant and so we can just delete it:

// #Preview("Load data failure") {
//   SyncUpsListView(
//     store: Store(initialState: SyncUpsList.State()) {
//       SyncUpsList()
//     } withDependencies: {
//       $0.dataManager = .mock(
//         initialData: Data("!@#$% bad data ^&*()".utf8)
//       )
//     }
//   )
//   .previewDisplayName("Load data failure")
// }

Ideally this would just work, but sadly there are a few quirks of SwiftUI previews that prevent it from working.

First of all, for whatever reason Xcode compiles the targets tests when building for previews. This certainly must be a bug in Xcode. It slows down compilation for previews and if you have a compilation error in your tests it will break your previews.

And of course we have a bunch of test compilation errors right now because we just commented out a bunch of code. Let’s quickly just comment out the AppFeatureTests and SyncUpsListTests

Now the preview runs, but it’s blank.

This is happening because when the preview runs, the app entry point of your application is also created. And this is biting us in the butt right now. The act of creating the entry point means we are creating a store:

@main
struct SyncUpsApp: App {
  let store = Store(…) { … }
  
  …
}

The act of creating a store also creates AppFeature.State:

Store(initialState: AppFeature.State()) { … }

The act of create AppFeatureState also creates SyncUpsList.State:

var syncUpsList = SyncUpsList.State()

And finally that causes the @Shared property wrapper to be created, and hence it loads its initial data from disk:

@Shared(.fileStorage(.syncUps))
var syncUps: IdentifiedArrayOf<SyncUp> = []

In previews we don’t use the live file system to back the fileStorage persistence strategy because that would cause changes in one preview run to leak over into another, which can be annoying. And so by default previews use the in-memory file storage that we mentioned a moment ago and that is used for UI testing.

This means each preview run gets a clean slate of a file system, with no data saved, and so when this @Shared property wrapper is created it sees that there is no data saved at the .syncUps URL, and therefore initializes with the default value provided, which is an empty array. And now all shared references to sync ups are set to an empty array.

There are two ways you can fix this.

Probably the simplest is to declare the shared state with an initial value of an empty array, and then immediately set it with the data you want:

#Preview {
  @Shared(.fileStorage(.syncUps))
  var syncUps: IdentifiedArrayOf<SyncUp> = []
  syncUps = [
    .mock,
    .designMock,
    .engineeringMock,
  ]
  …
}

That gets the previewing working correctly.

However, if you don’t want to have to remember this trick each time you run a preview you can instead alter your entry point to not construct the store if in previews. That can be done by making the store a static so that it is only constructed a single time for the lifetime of the application:

@main
struct SyncUpsApp: App {
  @MainActor
  static let store = Store(…) { … }
  …
}

And then we need to qualify the store variable with Self:

AppView(store: Self.store)

With that change we can go back to the previous style of setting shared state for our preview and it will work:

#Preview {
  @Shared(.fileStorage(.syncUps))
  var syncUps: IdentifiedArray = [
    SyncUp.mock,
    .designMock,
    .engineeringMock,
  ]
  …
}

Feel free to use whichever style you like best, but either way you should always be aware of these kinds of caveats when using Xcode previews.

Now the entire application compiles, works exactly as it did before, but we were able to delete well over 100 lines of code, and only had to significantly change one single line:

@Shared(.fileStorage(.syncUps))
var syncUps: IdentifiedArrayOf<SyncUp> = []

We just annotate the syncUps field with @Shared(.fileStorage(…)) and the library takes care of the rest.

We can even improve our usage of the @Shared property wrapper a bit. Right now there are two things that are not so ideal about this:

@Shared(.fileStorage(.syncUps))
var syncUps: IdentifiedArrayOf<SyncUp> = []

First, and most importantly, there is nothing tying the type of data we are actually saving on disk to the type of data that we want to hold in our application. And this is just generally a problem with serializing data to an untyped format, such as raw data bytes that are saved to a file system.

There is nothing stopping us from accidentally reusing the .syncUps URL for file storage somewhere else in our app, but for a completely different type of data, such as a plain array instead of an identified array:

@Shared(.fileStorage(.syncUps))
var otherSyncUps: [SyncUp] = []

It’s subtle, but this usage of @Shared is completely different from our other usage of it because the data types are different. This means these @Shared variables will not actually share state, and your application will be subtly broken.

And really this is no different from how SwiftUI’s persistence property wrappers work too. For example, if you do something like this in one part of your app to represent a count integer stored in user defaults:

@AppStorage("count") var count = 0

…and then in another part of your application do this to represent that a “count” setting is turned on:

@AppStorage("count") var count = true

…you will have problems. Swift and SwiftUI don’t do anything to prevent you from doing this, and these two app storage values will just trample on each other and cause subtle bugs.

But there is something we can do to make this better with the @Shared property wrapper. The protocol at the root of the persistence system in the library is called PersistenceKey. The .fileStorage strategy conforms to this protocol in order to provide its functionality. We can provide a static property on PersistenceKey that describes the persistence strategy:

extension PersistenceKey {
  static var syncUps: <#???#> {
    fileStorage(.syncUps)
  }
}

And then we would be able to use this directly in @Shared without even needing to specify what kind of persistence is being used under the hood:

@Shared(.syncUps)
var syncUps: IdentifiedArrayOf<SyncUp> = []

This is great for reuse because anyone wanting access to this collection can just use .syncUps and doesn’t need to further think about what kind of persistence is being used.

But, in order for this syntax to work we need to specify the type of persistence strategy being returned. The .fileStorage helper returns something of type FileStorageKey which is generic over the type of data persisted to disk:

extension PersistenceKey {
  static var syncUps: FileStorageKey<
    IdentifiedArrayOf<SyncUp>
  > {
    fileStorage(.syncUps)
  }
}

However, this isn’t enough to get things compiling because the fileStorage helper is defined only on a very specific constrained extension of PersistenceKey, in particular on FileStorageKey, so we must prefix with that:

extension PersistenceKey {
  static var syncUps: FileStorageKey<
    IdentifiedArrayOf<SyncUp>
  > {
    FileStorageKey.fileStorage(.syncUps)
  }
}

This extension is now compiling, but our usage of the static does not, and it actually has a pretty helpful error message with a fix-it:

Contextual member reference to static property ‘syncUps’ requires ‘Self’ constraint in the protocol extension

Missing same-type requirement on ’Self’

Applying the fix-it adds a where clause to our extension:

extension PersistenceKey where Self == <#Type#> {
  static var syncUps: FileStorageKey<
    IdentifiedArrayOf<SyncUp>
  > {
    FileStorageKey.fileStorage(.syncUps)
  }
}

We need to constrain this extension to only work on the FileStorageKey conformance of PersistenceKey:

extension PersistenceKey
where Self == FileStorageKey<IdentifiedArrayOf<SyncUp>> {
  static var syncUps: FileStorageKey<
    IdentifiedArrayOf<SyncUp>
  > {
    FileStorageKey.fileStorage(.syncUps)
  }
}

This now compile, but also seems really noisy. Luckily we can simplify this quite a bit:

extension PersistenceKey
where Self == FileStorageKey<IdentifiedArrayOf<SyncUp>> {
  static var syncUps: Self {
    fileStorage(.syncUps)
  }
}

And this is how you can create type-safe, reusable keys to be used with @Shared. This ties the type of data we want to save to disk with the URL being used to identify the file on disk.

In fact, if we try to use a different data type now:

@Shared(.syncUps) var syncUps: [SyncUp] = []

…we get an error. And further, because the type of the data is known from .syncUps alone we can even drop some of the type annotations:

@Shared(.syncUps)
var syncUps: IdentifiedArray = []

But we can further improve this. Right now anytime we want access to the shared sync ups we need to provide a default. The empty array is a reasonable default to provide, but it’s weird to have to repeatedly provide it, and it would be weird if anyone ever provided something else for the default.

So, we can tweak our syncUps persistence static by using the PersistenceKeyDefault tool:

extension PersistenceKey
where Self == PersistenceKeyDefault<
  FileStorageKey<IdentifiedArrayOf<SyncUp>>
> {
  static var syncUps: Self {
    PersistenceKeyDefault(.fileStorage(.syncUps), [])
  }
}

And now we can now drop the default anytime we want to reference the shared sync ups:

@Shared(.syncUps) var syncUps

So this is now more concise, type-safe, and will be easier to reuse throughout the app.

And we’d also like to mention that this feature came to the library during the beta period of the shared state tools, and in particular was pitched and implemented by one of our viewers, Luke Redpath. So thanks Luke for your suggestion and work to bring this to life.

Shared sync-up details

It doesn’t feel like we’ve even done much yet, but already this is pretty incredible. We changed one single line of code and as a result we were able to delete over a hundred lines of code.

And the flip side to that is if we were to build this SyncUps app from scratch it would mean we would have been able to use the @Shared(.fileStorage) property wrapper from the beginning, and instantly gotten persistence and state sharing, for free. No additional work would have been necessary.

Brandon

It’s great to see that the @Shared property and its persistence strategies are making it possible for us to delete a bunch of code.

But things get even better. As we mentioned before, we are currently sending some delegate actions in the app just to perform manual synchronization of data between various parts of the app. It’s a pain to do, it’s easy to get wrong, and this is exactly the kind of thing that our @Shared property wrapper was built to handle.

Let’s try refactoring that code now.

Recall that the SyncUpDetail feature has this special delegate action:

case syncUpUpdated(SyncUp)

…whose purpose is just to communicate to a parent feature that its sync up changed. Then the parent feature will take that data and update other parts of the application that need to have the freshest sync-up data.

And we sent that delegate action by using the onChange reducer in order to see exactly when the state changes:

.onChange(of: \.syncUp) { oldValue, newValue in
  Reduce { state, action in
    .send(.delegate(.syncUpUpdated(newValue)))
  }
}

Well, this is quite convoluted, and it also it sounds like exactly the kind of problem that shared state was designed to solve. So, let’s delete all of that synchronization logic and see what it can be replaced with.

We no longer want to do this:

// .onChange(of: \.syncUp) { oldValue, newValue in
//   Reduce { state, action in
//     .send(.delegate(.syncUpUpdated(newValue)))
//   }
// }

And we can remove the delegate action:

@CasePathable
enum Delegate {
  case deleteSyncUp
  // case syncUpUpdated(SyncUp)
  case startMeeting
}

This is already looking great.

But we do have a compiler error back in the AppFeature, where we are handling that delegate action in order to play changes from the detail feature back to the list feature. All of this logic also goes away:

// case let .syncUpUpdated(syncUp):
//   state.syncUpsList.syncUps[id: syncUp.id] = syncUp
//   return .none

The project is now compiling, and it would be fantastic if it just somehow worked, but sadly that is a little too optimistic.

If we launch the app in the simulator, and make an edit to an existing sync up, it will seem as if the edits were applied on the detail screen. But if we go back to the list we will see that the edit was not made. And relaunching the app also shows that the changes were not persisted.

We also have an extensive test suite that hopefully would catch problems like this without us having to run the app in the simulator, and in fact it would catch this, but unfortunately they are not in compiling order at the moment.

And of course the reason the app and tests are broken is because although we are using Shared in one feature, in particular the SyncUpList feature, we aren’t using it other places, such as the SyncUpDetail feature. We need to leverage the @Shared property wrapper in any feature that wants to be able to edit to the shared state.

So, we will upgrade the syncUp property in SyncUpDetail.State’s to be shared:

@Shared var syncUp: SyncUp

And note that we are using the basic version of @Shared here without specifying a persistence strategy. That’s because this feature doesn’t need to be concerned with persistence. And in fact it would not be right to specify a persistence strategy, such as .fileStorage:

@Shared(.fileStorage(URL(…))) var syncUp: SyncUp

This is saying that we need to persist this single sync up in a particular file on disk. But we want to persist all sync ups in a single file. It is not correct to persist just this one single sync up.

So, by using an unadorned @Shared value:

@Shared var syncUp: SyncUp

…we allow our features to communicate that it wants to hold onto some state that is shared with another part of the application, but that it is not concerned with how, or if, the data is persisted. Instead, we can derive a shared reference to a single sync up from our persisted collection of sync ups, and then any edit this feature makes to it will automatically be persisted.

This is actually quite similar to how the @Binding property wrapper works in SwiftUI. When a view holds onto an @Binding like this:

struct SomeView: View {
  @Binding var count = 0
  var body: some View { EmptyView() }
}

…it doesn’t know anything about how that binding was derived. It could have come from an @State property in the parent, or an observable model held in the parent using the @Bindable property wrapper, or from a @Published property if using the older style ObservableObject protocol, or it could have been derived from an @AppStorage property in the parent, or even constructed directly from the Binding.init(get:set:) initializer. All this view cares about is that it holds onto some state whose “source of truth” lies elsewhere, and it wants to be able to write to the state so that the parent’s state also mutates, and it wants to be able to get the freshest state from the parent whenever the parent makes a mutation.

In our opinion the @Shared property wrapper is basically the Composable Architecture’s version of the @Binding property wrapper from SwiftUI. It just facilitates communication between different features through state mutations.

However, this change does create a few compiler errors, such as down in the preview where we need to wrap a .mock sync up with Shared(…):

store: Store(
  initialState: SyncUpDetail.State(syncUp: Shared(.mock))
) {
  …
}

This is perfectly fine to do. This is just us seeding the preview with a bit of shared state that now this feature owns rather than it being derived from a parent feature and handed down.

The next error is in the SyncUpsListView where it is no longer correct to link out to the detail screen with a basics sync up:

NavigationLink(
  state: AppFeature.Path.State.detail(
    SyncUpDetail.State(
      syncUp: syncUp
    )
  )
) {
  …
}

We need to provide a shared sync up, but also it isn’t correct to just create a whole new shared value right in line:

NavigationLink(
  state: AppFeature.Path.State.detail(
    SyncUpDetail.State(
      syncUp: Shared(syncUp)
    )
  )
) {
  …
}

That Shared value is fully untethered from the shared sync ups collection, and therefore any edits made to it will not make their way back to the root collection of sync ups.

What we need to do is derive a shared sync up from the shared collection of sync ups, and luckily we can do this thanks to some of the tools that come with the library. In past episodes we developed some tools from scratch for deriving shared state from existing shared state, but this tool in particular we did not have time to cover.

The current problem is that right now we are using a simple ForEach on the collection of sync ups:

ForEach(store.syncUps) { syncUp in
  …
}

The syncUps field is just the regular, wrapped value from the property wrapper, and its type is IdentifiedArrayOf<SyncUp>.

We can instead reach for the projected value of the property wrapper:

ForEach(store.$syncUps) { syncUp in 
  …
}

This $syncUps field is a Shared<IdentifiedArrayOf<SyncUp>>. So, that’s getting us closer, but this is not something we can hand directly to ForEach. The ForEach view needs a mutable, random access collection, and the Shared type does not conform to those protocols.

In fact, it specifically forbids a conformance:

Note

🛑 Conformance of ‘Shared’ to ‘RandomAccessCollection’ is unavailable: Derive shared elements from a stable subscript, like ‘$array[id:]’ on ‘IdentifiedArray’, or pass ‘$array.elements’ to a ‘ForEach’ view.

And it helpful suggests to us what we should do. It lets us know that we can use the elements property to derive a collection that is appropriate to use with ForEach:

ForEach(store.$syncUps.elements) { syncUp in 
  …
}

This turns a shared collection into a collection of shared values. And further, we can capture the projected value of the property value in the trailing closure by using the $ syntax:

ForEach(store.$syncUps.elements) { $syncUp in 
  …
}

Now $syncUp refers to a shared sync up that has been derived from the shared collection, and an unadorned syncUp refers to just the simple SyncUp value.

This $syncUp is exactly what we can pass along to the SyncUpDetail.State:

NavigationLink(
  state: AppFeature.Path.State.detail(
    SyncUpDetail.State(syncUp: $syncUp)
  )
) {
  CardView(syncUp: syncUp)
}

And now this is compiling. We are handing a piece of shared state to the detail feature that is connected to the root collection of sync ups that powers the app, and this means that any mutation the detail feature makes to that state it will be instantly seen by the root list feature.

This means that the app is now back to working without any further changes. That’s right, when the “Done” button is tapped while editing the sync up we currently mutate the detail’s syncUp like so:

case .doneEditingButtonTapped:
  guard case let .edit(editState) = state.destination
  else { return .none }
  state.syncUp = editState.syncUp
  state.destination = nil
  return .none

…and that alone is enough for that mutation to reverberate all the way back to the SyncUpsList feature. There is no more need for delegate actions or onChange reducers just to communicate to the parent that something has changed.

Let’s run the simulator to see that everything is working again.

We can make edits to a sync up from the detail screen and it is automatically synchronized to the root list screen without us doing any extra work. And that’s because the detail screen’s shared state is directly linked to the shared state in the list screen. Any change it makes will be immediately applied to the list screen, which is incredible.

And this even goes for persistence. Any changes the detail screen makes to the state will be automatically seen by the fileStorage persistence strategy, and it will save those changes to disk, but only after 5 seconds pass so that it doesn’t thrash the disk with each little change.

And further, the fileStorage strategy is always listening for changes to the data on disk so that it can update the value held in the @Shared property wrapper, and this behave extends even to derived shared state, which is what the detail feature is holding onto. This means if we do something silly like wait for a few seconds to pass after the app launches and then edit the data on disk to change the title of the first sync up:

@main
struct SyncUpsApp: App {
  …

  init() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
      var syncUps = try! JSONDecoder().decode(
        [SyncUp].self, from: Data(contentsOf: .syncUps)
      )
      syncUps[0].title = "Point-Free evening sync"
      try! JSONEncoder()
        .encode(syncUps)
        .write(to: .syncUps)
    }
  }

  …
}

We can launch the app, quickly navigate to the first sync-up’s detail screen, and after 3 seconds the title of the meeting will magically update. And if we pop back to the list view we will see the row’s title was updated there too.

Next time: Updating tests

We now have two significant parts of the application using shared state: the list feature uses the @Shared property wrapper to persist a collection of sync-ups to disk whenever the data changes, and the detail feature uses @Shared to express that it wants to make changes to a piece of state that are visible elsewhere. It’s awesome to see how everything hooks up to each other, and it’s very reminiscent of how bindings work in vanilla SwiftUI. In fact, we do like to think of @Shared as being the Composable Architecture version of bindings from SwiftUI.

Stephen

So, this seems great, but how does it affect tests? We are now doing two things in our state that historically do not play nicely with testing. First, we have persistence, which means some interaction with the file system, which is a global blob of data that anyone can write to. And we have state sharing, which means a reference type is involved, and reference types are notoriously tricky to test since they don’t have a well-accepted notion of equality and since they cannot be copied.

Well, luckily our @Shared property wrapper has none of these issues. It is completely testable, and even exhaustively testable.

Let’s take a look…next time!


References

Downloads

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