🎉 End-of-year Sale! Save 25% when you subscribe today.

Shared State in Practice: SyncUps: Part 2

Episode #278 • May 6, 2024 • Free Episode

We finish refactoring the SyncUps application to use the Composable Architecture’s all new state sharing tools. We will see that we can delete hundreds of lines of boilerplate of coordination between parent and child features, and we won’t have to sacrifice any testability, including the exhaustive testability provided by the library.

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

Brandon

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.

Updating tests

If we try building for tests we get a bunch of failures and that’s because we’ve made a lot of changes to our application’s code.

Earlier, to get previews building, I commented out a lot of test code, so let’s just comment out the rest of the failures so that we can bring tests back more slowly.

Let’s start with SyncUpsListTests and see how we can fix them…

The first thing that stands out in the SyncUpListsTests.swift file is that there are still references to the data manager dependency. Let’s start by getting rid of all references to dataManager:

We can start fixing these in the SyncUpsListTests.swift file, where we will simply delete many mention of the dataManager dependency…

And then in testLoadingDataDecodingFailed and testLoadingDataFileNotFound we can just comment out those tests because they are testing behavior that is no longer in the app, that of showing alerts:

// func testLoadingDataDecodingFailed() async throws {
//   let store = TestStore(
//     initialState: SyncUpsList.State()
//   ) {
//     SyncUpsList()
//   } withDependencies: {
//     $0.continuousClock = ImmediateClock()
//     $0.dataManager = .mock(
//       initialData: Data("!@#$ BAD DATA %^&*()".utf8)
//     )
//   }
//
//   XCTAssertEqual(
//     store.state.destination, .alert(.dataFailedToLoad)
//   )
//
//   await store.send(
//     .destination(
//       .presented(.alert(.confirmLoadMockData))
//     )
//   ) {
//     $0.destination = nil
//     $0.syncUps = [
//       .mock,
//       .designMock,
//       .engineeringMock,
//     ]
//   }
// }
//
// func testLoadingDataFileNotFound() async throws {
//   let store = TestStore(
//     initialState: SyncUpsList.State()
//   ) {
//     SyncUpsList()
//   } withDependencies: {
//     $0.continuousClock = ImmediateClock()
//     $0.dataManager.load = { @Sendable _ in
//       struct FileNotFound: Error {}
//       throw FileNotFound()
//     }
//   }
//
//   XCTAssertEqual(store.state.destination, nil)
// }

That gets SyncUpsListTests compiling, and in fact the whole class also passes. So nothing about our change of the syncUps property to use the @Shared property affected any of the feature’s logic, at least as far as the test suite can tell.

And that is at least a little impressive because technically we are dealing with the file system now. Every change to the sync-ups is persisted to the file system, and so that leaves open the possibility that changes to the file system will carry over from one test to the other.

That typically can be a pain, but we have taken the time to properly control our dependency on the underlying file system, and so in tests we don’t actually ever write a file to disk. All the data is kept in memory, and that’s what makes it possible to write deterministic tests that pass every time.

Next let’s comment in SyncUpDetailTests.swift. Let’s focus on just the detail tests to begin, and so we will comment out all of the app feature tests…

The first error we see in the detail tests is that we are constructing a test store by providing a mock sync up, but now that needs to be wrapped in a Shared value:

let store = TestStore(
  initialState: SyncUpDetail.State(syncUp: Shared(.mock))
) {
  …
}

And we have to make that change in a few spots in this file…

We have just one more failure where we expected to receive a syncUpUpdated delegate action. This action no longer exists, as state is now automatically synchronized via the @Shared property wrapper, so we can delete the assertion:

// await store.receive(\.delegate.syncUpUpdated)

That gets tests compiling, so now let’s run tests. Amazingly they pass! So the act of upgrading the syncUp held in the detail state to use @Shared does not actually affect any of the logic of the feature. So that’s great.

Let’s now uncomment the app feature tests to see how to get it into compiling order.

Again there are a lot of mentions of dataManager, and so let’s fix those. For example, in testDetailEdit we are using the dataManager dependency to seed the test with a sync up. We can now do this by initializing the shared state before constructing the test store just like we did in previews:

@Shared(.syncUps) var syncUps = [syncUp]
let store = TestStore(initialState: AppFeature.State()) {
  AppFeature()
} withDependencies: {
  $0.continuousClock = ImmediateClock()
  // $0.dataManager = .mock(
  //   initialData: try! JSONEncoder().encode([syncUp])
  // )
}

The next error is where we are emulating the user tapping on a sync up card in the list view, which should then navigate the user to the detail screen:

await store.send(
  \.path.push,
  (id: 0, .detail(SyncUpDetail.State(syncUp: syncUp)))
) {
  $0.path[id: 0] = .detail(
    SyncUpDetail.State(syncUp: syncUp)
  )
}

These lines are not compiling because the SyncUpDetail.State now takes a shared sync up rather than a plain sync up.

We can get things compiling by maybe wrapping these in Shared:

await store.send(
  \.path.push,
  (
    id: 0,
    .detail(SyncUpDetail.State(syncUp: Shared(syncUp)))
  )
) {
  $0.path[id: 0] = .detail(
    SyncUpDetail.State(syncUp: Shared(syncUp))
  )
}

And next we’re still expecting to receiving a syncUpUpdated delegate action, which no longer exists, and update that sync-up in the list, this now happens automatically when the doneEditingButtonTapped action is sent, so we can squash the two assertions into one:

await store.send(
  \.path[id:0].detail.doneEditingButtonTapped
) {
  $0.path[id: 0]?.detail?.destination = nil
  $0.path[id: 0]?.syncUp.title = "Blob"
// }
// await store.receive(
//   \.path[id:0].detail.delegate.syncUpUpdated
// ) {
  $0.syncUpsList.syncUps[id: 0]?.title = "Blob"
}

That gets this test compiling, so let’s run it.

Well, we actually get a crash where we are performing this assertion:

XCTAssertNoDifference(
  try JSONDecoder().decode(
    [SyncUp].self, from: savedData.value!
  ),
  [savedSyncUp]
)

…with this failure:

Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value

We probably should have used XCTUnwrap to avoid hard crashing the suite, and if we do we get a more reasonable failure.

This is explicitly asserting that data was saved with the data manager, but there is no more data manager, and all saving is handled implicitly for us by the @Shared property wrapper. This is now behavior we just don’t need to assert on anymore. We can trust that the library is going to handle it to the best of its ability:

// XCTAssertNoDifference(
//   try JSONDecoder().decode(
//     [SyncUp].self, from: savedData.value
//   ),
//   [savedSyncUp]
// )

If we run the test we will see it fails when emulating the user tapping the “Done” button, which should cause the detail’s sheet to be dismissed and for the sync up data to be updated. The error we are getting:

A state change does not match expectation: …

  AppFeature.State(
    _path: […],
    _syncUpsList: SyncUpsList.State(
      _destination: nil,
      _syncUps: #1 IdentifiedArray(
        SyncUp(
          id: Tagged(
            rawValue: UUID(
              B8D3FFF5-88B1-4341-BCED-9BBD7BB738C0
            )
          ),
          attendees: […],
          duration: 1 minute,
          meetings: […],
          theme: .orange,
−         title: "Blob"
+         title: "Design"
        )
      )
    )
  )

(Expected: −, Actual: +)

…tells us that the state did not update.

The reason for this is because when pushing the detail feature onto the stack we created a brand new Shared reference from scratch:

await store.send(
  \.path.push,
  (
    id: 0,
    .detail(SyncUpDetail.State(syncUp: Shared(syncUp)))
  )
) {
  …
}

This means any change the detail feature makes to that shared state will be completely untethered to the shared state in the sync ups list. This is very similar to what we saw in the app a moment ago too. If you hand the detail feature a sync up that is disconnected from the collection of sync ups, then there should be no expectation that edits to it will also change the value in the collection.

To fix this we need to derive a shared sync-up from the collection of sync-ups. The best way to do this is to use the shared sync ups we already have in the test, and use dynamic member look up to find the sync up we are interested in:

var syncUp = SyncUp.mock
@Shared(.syncUps) var syncUps = [syncUp]
let sharedSyncUp = $syncUps[id: syncUp.id]

However, because the id subscript returns an optional we technically have an optional piece of shared state here:

let sharedSyncUp: Shared<SyncUp>? = $syncUps[id: syncUp.id]

To get an honest piece of shared state we need to unwrap it, which we can do with XCTUnwrap, that way the test will fail if the optional is ever nil for some reason:

let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id])

And then this is the shared sync up reference we can pass to the action:

await store.send(
  \.path.push,
  (
    id: 0,
    .detail(SyncUpDetail.State(syncUp: sharedSyncUp))
  )
) {
  …
}

That is all it takes and now this test is passing.

Let’s fix the rest of the tests and get them passing.

Getting rid of even more delegate actions

We have now updated the test suite so that it is compiling and passing, and it only took a few small changes. For the most part we were able to delete code from tests, such as worrying about data persisting to the file system. We no longer need to test that logic because the @Shared property takes care of it for us, and that tool has an extensive test suite, so we can trust that it is doing the right thing.

Further, it’s amazing to see that we can still exhaustively test our features when they use shared state. At the end of the day, shared state is a reference-y concept, and so that can inject a bit of uncertainty into our features. But the Composable Architecture goes through great lengths to make sure the state is still exhaustively testable, forcing us to assert on exactly how state changes when an action is sent.

Brandon

Yep, things are looking great, but we still have some delegate actions in the app right now whose only purpose is to communicate to the parent that it needs to make a mutation to the sync ups collection. Such delegate actions used to be necessary, but now with the @Shared property wrapper we can implement this kind of logic in a far simpler manner.

Let’s give it a shot.

We currently have two sets of delegate actions in the app right now. There’s one in the detail feature for telling the parent to delete the sync up and for telling the parent to start a new meeting:

@CasePathable
enum Delegate {
  case deleteSyncUp
  case startMeeting
}

And then there’s one in the record meeting feature for telling the parent to save the meeting in the sync up’s history, and a transcript is provided:

@CasePathable
enum Delegate {
  case save(transcript: String)
}

It turns out all but one of these cases can be completely eliminated. The deleteSyncUp case and save case exist only to tell the parent to update the data in the collection of sync ups, and then the data will be persisted shortly after. But now that we have immediate access to the shared state whenever we want, what if we could just perform those mutations directly in the child feature and not involve the parent feature at all?

It’s completely possible, so let’s give it a shot. Let’s start with the deleteSyncUp delegate action. Let’s comment out that case so that we can see how to implement the same functionality in a more direct fashion:

// case deleteSyncUp

The first compilation error is in the reducer when deletion is confirmed by the user, in which case we send the delegate action and dismiss the feature:

case .confirmDeletion:
  return .run { send in
    await send(
      .delegate(.deleteSyncUp), animation: .default
    )
    await self.dismiss()
  }

What if we could reach right into the globally shared sync-ups collection and remove the sync up directly from it? We could even use the @Shared property wrapper inline, right inside the reducer:

case .confirmDeletion:
  @Shared(.syncUps) var syncUps
  syncUps.remove(id: state.syncUp.id)
  return .run { send in
    // await send(
    //   .delegate(.deleteSyncUp), animation: .default
    // )
    await self.dismiss()
  }

It may seem strange, but this is a completely legitimate thing to do. In really, it’s not all that different from how SwiftData works either.

In SwiftData you always have access to the model context, which gives you full unfettered access to the entire database of objects. In fact, the model context is usually put directly into SwiftUI’s environment, which means it truly is available everywhere. Any view can reach out and grab the context without any ceremony. And with that context you can do anything to the database you want, such as insert to objects, fetch objects, and even delete objects.

So we don’t really think it’s all that different in this situation. However, things still are not compiling, and that’s because the app feature is still referencing the deleteSyncUp action:

case .deleteSyncUp:
  state.syncUpsList.syncUps.remove(
    id: detailState.syncUp.id
  )
  return .none

Well, none of this logic is necessary anymore, so we can just get rid of it:

// case .deleteSyncUp:
//   state.syncUpsList.syncUps.remove(
//     id: detailState.syncUp.id
//   )
//   return .none

That’s all it takes, and the app works exactly as it did before, but we have gotten rid of yet another delegate action. We can run the app, drill down to a sync up, delete it, and then the feature pops off the stack and we see that the sync up was removed from the list.

This is pretty incredible. This is yet more logic that we can encapsulate directly in a child feature without it needing to communicate up to a parent. This makes child features more isolatable, and makes it easier for the parent to integrate the child feature into its domain.

And because we have such easy access to sync-ups list state anywhere we can do some really fun things. What if we want to pop the detail view off the stack and then, a moment later, animate the sync-up deletion away. Well we can simply move the deletion logic into the effect and execute it after sleeping a short while. We just need to move the sleep into an unstructured task since dismiss cancels the effect, and we don’t want the sleep to participate in cooperative cancellation, and we can wrap the deletion in withAnimation to animate it:

case .confirmDeletion:
  return .run { [id = state.syncUp.id] _ in
    await self.dismiss()
    try await Task {
      try await Task.sleep(for: .seconds(0.5))
    }
    .value
    @Shared(.syncUps) var syncUps
    _ = withAnimation {
      syncUps.remove(id: id)
    }
  }

And it all just works! It’s cool to see how precise we can be in accessing and mutating shared state. But let’s go back to the old behavior, since these changes aren’t currently in a testable state…

Next let’s take a look at the delegate action in the record feature:

@CasePathable
enum Delegate {
  case save(transcript: String)
}

I want this feature to save the transcript without communicating back to the parent feature, so let’s comment out the Delegate enum entirely:

// @CasePathable
// enum Delegate {
//   case save(transcript: String)
// }

And the delegate action case:

enum Action {
  // case delegate(Delegate)
  …
}

We were sending this delegate action in two places. First when ending the meeting early and the user confirms to save the meeting:

case .alert(.presented(.confirmSave)):
  return .run { [transcript = state.transcript] send in
    await send(.delegate(.save(transcript: transcript)))
    await self.dismiss()
  }

And then again when time runs out in the meeting:

if state.secondsElapsed.isMultiple(of: secondsPerAttendee) 
{
  if state.speakerIndex
    == state.syncUp.attendees.count - 1
  {
    return .run { [transcript = state.transcript] send in
      await send(.delegate(.save(transcript: transcript)))
      await self.dismiss()
    }
  }
  state.speakerIndex += 1
}

Instead of sending a delegate action, we want to mutate the sync up we hold in state, directly in the reducer:

case .alert(.presented(.confirmSave)):
  state.syncUp.meetings.insert(
    Meeting(
      id: Meeting.ID(),
      date: Date(),
      transcript: state.transcript
    ),
    at: 0
  )
  return .run { [transcript = state.transcript] send in
    // await send(.delegate(.save(transcript: transcript)))
    await self.dismiss()
  }

And we can do similar below.

That’s a lot better, but we did have to make some decisions about how to generate a new Meeting.ID and a date, but we will worry about those things later.

But also this can’t possibly be right. The feature’s state is just holding onto a plain SyncUp:

var syncUp: SyncUp

Mutations to this state will not be seen by any other part of the app.

We need to upgrade this to be @Shared state, and then any mutations we make to it will be visible to any other feature holding onto a reference to that state:

@Shared var syncUp: SyncUp

We will have to update a few spots to properly use shared state. First, we have the preview:

#Preview {
  NavigationStack {
    RecordMeetingView(
      store: Store(
        initialState: RecordMeeting.State(
          syncUp: Shared(.mock)
        )
      ) {
        RecordMeeting()
      }
    )
  }
}

And then when starting a meeting we need to pass along the shared sync up rather than the plain sync up, which we can do via the projected value:

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

And while we are here, let’s go ahead and comment out the destructuring of the delegate action below, because that is precisely the action we are trying to get rid of:

// 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
//   }

Now everything is compiling and it works exactly as it did before. We can run the app in the simulator and see that recording a new meeting works exactly as it did before.

Fixing more tests

We have now gotten rid of even more delegate actions, and the detail feature and record feature are now even more independent from the rest of the application. It just needs to access to a piece of shared state so that it can make a mutation to it, but it doesn’t care where the state comes from or if or how it is persisted.

Stephen

So again we have improved the isolation of our features by embracing shared state, but what has this done to our tests? I imagine that our tests are not compiling anymore because we have gotten rid of 2 more delegate actions, but we can still mostly test our features just like they were previously being tested.

Let’s take a look

There’s one compilation error in the detail tests because we are asserting that the delegate action is sent, but we no longer need to do that:

// await store.receive(\.delegate.deleteSyncUp)

And then there are compilation errors in the app feature tests and record meeting tests, let’s comment them out for now so that we can run the detail tests…and they pass!

We can now comment the record meeting tests back in, where there are a bunch of failures because we just introduced shared state to the feature, and got rid of a delegate action.

First, the state of the feature now takes a shared sync-up, so wherever we are passing a SyncUp along, we can wrap all the data we initialized with Shared:

let store = TestStore(
  initialState: RecordMeeting.State(
    syncUp: Shared(…)
  )
) { … }

Next, wherever we were asserting against receiving a delegate action, we can simply omit that step:

// await store.receive(\.delegate.save)

And with those changes we are in building order again, and if we run the tests, we do get some failures:

A state change does not match expectation: …

  RecordMeeting.State(
    _alert: nil,
    _secondsElapsed: 6,
    _speakerIndex: 2,
    _syncUp: #1 SyncUp(
      id: Tagged(
        rawValue: UUID(
          8F521F00-50F0-4FF6-9446-9087C7345BE0
        )
      )
    ),
    attendees: […],
    duration: 6 seconds,
    meetings: [
+     [0] Meeting(
+       id: Tagged(
+         rawValue: UUID(
+           941C1EE2-FE9E-4068-9FFD-5A054DBA7C89
+         )
+       ),
+       date: Date(2024-04-22T21:27:19.125Z),
+       transcript: ""
+     )
    ]
  )

(Expected: −, Actual: +)

Now that the record feature is responsible for inserting the meeting directly into the shared state, we must assert on this state instead of the delegate action we used to assert against.

await store.receive(\.timerTick) {
  …
  $0.syncUp.meetings.insert(
    Meeting(
      id: Meeting.ID(),
      date: Date(),
      transcript: ""
    ),
    at: 0
  )
}

We are invoking UUID and date dependencies in an uncontrolled way, so this should fail, but we can at least make some progress. And it does fail, but in a smaller way, and we can now see that the only things we need to do is control these dependencies.

A state change does not match expectation: …

  RecordMeeting.State(
    _alert: nil,
    _secondsElapsed: 6,
    _speakerIndex: 2,
    _syncUp: #1 SyncUp(
      id: Tagged(
        rawValue: UUID(
          8F521F00-50F0-4FF6-9446-9087C7345BE0
        )
      )
    ),
    attendees: […],
    duration: 6 seconds,
    meetings: [
      [0] Meeting(
        id: Tagged(
          rawValue: UUID(
−           941C1EE2-FE9E-4068-9FFD-5A054DBA7C89
+           2B898B54-78CE-4227-BE59-172AE100FBA0
          )
        ),
−       date: Date(2024-04-22T21:28:16.064Z),
+       date: Date(2024-04-22T21:28:16.060Z),
        transcript: ""
      )
    ]
  )

(Expected: −, Actual: +)

We can introduce these dependencies directly to the record meeting feature:

@Reducer
struct RecordMeeting {
  …
  @Dependency(\.date.now) var now
  @Dependency(\.uuid) var uuid
}

And then we can call out to these controlled dependencies when inserting a meeting from the reducer:

state.syncUp.meetings.insert(
  Meeting(
    id: Meeting.ID(uuid()),
    date: now,
    transcript: state.transcript
  ),
  at: 0
)

Back in the tests we must now override the dependencies in all of the tests.

} withDependencies: {
  $0.date.now = Date(timeIntervalSince1970: 1_234_567_890)
  $0.uuid = .incrementing
}

And if we return to the failing test, we just need to update the assertion to use the expected mock data:

$0.syncUp.meetings.insert(
  Meeting(
    id: Meeting.ID(UUID(0)),
    date: Date(timeIntervalSince1970: 1_234_567_890)
  ),
  at: 0
)

Hopefully we now have a passing test…and we do!

And it’s kind of amazing! We are inserting the meeting into the sync-up directly from the record meeting feature instead of having to communicate and delegate that work to the parent sync-up detail feature. We don’t have to write a more complex parent feature test to assert against this as integration logic. Instead, we can make a simpler assertion directly in the child.

But we still have a few test failures where we also need to assert against this logic, so let’s do that…

And now when we run things, all the record meeting tests are passing!

We are down to the final test suite, which is for the app feature. If we comment things in, it is currently failing because we have assertions against delegate actions, which we can now remove, and we can squash the state it was asserting against into the previous step, when the deletion is confirmed by the user we can simultaneously dismiss the alert and delete the sync up:

await store.send(
  \.path[id:0].detail.destination.alert.confirmDeletion
) {
  $0.path[id: 0]?.detail?.destination = nil
  $0.syncUpsList.syncUps = []
}

That’s much simpler, but the integration test with the record feature still has a compilation errors, and that’s because we need to wrap the sync up provided to RecordMeeting.State in a Shared reference:

.record(RecordMeeting.State(syncUp: sharedSyncUp)),

And we need to comment out another reference to the delegate action we deleted:

// await store.receive(\.path[id:1].record.delegate.save) {
//   $0.path[id: 0]?.detail?.syncUp.meetings = [
//     Meeting(
//       id: Meeting.ID(UUID(0)),
//       date: Date(timeIntervalSince1970: 1_234_567_890),
//       transcript: "I completed the project"
//     )
//   ]
// }

And if we run tests, they succeed! However, we are not currently asserting that a meeting was inserted into the sync up when it ends. That was being asserted on in the receive assertion of the save action, which is now commented out:

We would love to recapture this assertion, but where should we do it?

The TestStore as an assertion helper that allows you to assert on the current state in an ergonomic manner. It’s a method called assert that takes a trailing closure, and that closure is handed a mutable argument representing the current state of the store.

store.assert {
  _ = $0
}

You are free to make any mutations you want to this variable, but if the mutation changes it in anyway then you will get a test failure. So, we expect that the meetings array has a single element with the newly added meeting, and so we can assert that:

store.assert {
  $0.syncUpsList.syncUps[0].meetings = [
    Meeting(
      id: Meeting.ID(UUID(0)),
      date: Date(timeIntervalSince1970: 1_234_567_890),
      transcript: "I completed the project"
    )
  ]
}

If we run tests they succeed!

Next time: isowords

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…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