Using Swift Data and The Composable Architecture (TCA)

Harsh Vishwakarma
5 min readApr 16, 2024

--

Hey, fellow SwiftUI enthusiasts! Today, I’m excited to share some hard-earned wisdom with you. In this article, I’ll take you on a journey through the trials and tribulations I faced while using Swift Data with TCA in SwiftUI. But fear not! Along the way, we’ll uncover valuable insights and strategies to overcome these challenges and emerge victorious. So, grab your coffee, settle in, and let’s dive into the nitty-gritty of navigating Swift Data with TCA in SwiftUI.

When it comes to integrating Swift Data into your SwiftUI project, the process can seem straightforward at first glance. You create your model class, slap on the @Model macro, and voila – you're ready to rock and roll. Here's a quick rundown:

@Model
class Workout {
// Your properties and methods go here
}

@main struct WorkoutTrackerApp {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Workout.self])
}
}

But hold on just a second! While this approach might work fine for standalone projects, it’s not exactly TCA-friendly. If you’re diving deep into The Composable Architecture, you’ll need to make a few adjustments to ensure everything plays nice together.

Firstly, we can’t just rely on the @Model macro alone. Instead, we need to implement TCA's state management principles. This means breaking down our application state into distinct pieces and defining actions to mutate that state. Let's break it down step by step:

To integrate Swift Data with The Composable Architecture (TCA) in your SwiftUI project, you’ve made significant strides. Let’s break down the steps you’ve taken and discuss how they align with TCA principles.

Integrating Swift Data:

One common approach to manage Swift Data is through a singleton class, SwiftDataModelConfigurationProvider. This class manages the schema and any migration-related tasks. Here's how I set it up:

public class SwiftDataModelConfigurationProvider {
// Singleton instance for configuration
public static let shared = SwiftDataModelConfigurationProvider(isStoredInMemoryOnly: false, autosaveEnabled: true)

// Properties to manage configuration options
private var isStoredInMemoryOnly: Bool
private var autosaveEnabled: Bool

// Private initializer to enforce singleton pattern
private init(isStoredInMemoryOnly: Bool, autosaveEnabled: Bool) {
self.isStoredInMemoryOnly = isStoredInMemoryOnly
self.autosaveEnabled = autosaveEnabled
}

// Lazy initialization of ModelContainer
@MainActor
public lazy var container: ModelContainer = {
// Define schema and configuration
let schema = Schema(
[
Rep.self,
Exercise.self,
Workout.self,
]
)
let configuration = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly)

// Create ModelContainer with schema and configuration
let container = try! ModelContainer(for: schema, configurations: [configuration])
container.mainContext.autosaveEnabled = autosaveEnabled
return container
}()
}

Next, modify the WindowGroup in WorkoutTrackerApp to include the Swift Data model container:

@main struct WorkoutTrackerApp {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(SwiftDataModelConfigurationProvider.shared.container)
}
}

Aligning with TCA:

To seamlessly integrate Swift Data with TCA, a few adjustments are necessary to ensure smooth compatibility. Let’s delve into TCA integration. Leverage the TCA’s @Dependency framework to establish a global dependency, facilitating access to the ModelContext managed by Swift Data.

// Global Swift Data Dependency
extension DependencyValues {
var databaseService: Database {
get { self[Database.self] }
set { self[Database.self] = newValue }
}
}

struct Database {
var context: () throws -> ModelContext
}

extension Database: DependencyKey {
@MainActor
public static let liveValue = Self(
context: { appContext }
)
}

@MainActor
let appContext: ModelContext = {
let container = SwiftDataModelConfigurationProvider.shared.container
let context = ModelContext(container)
return context
}()

You’ve taken the first step by defining a Database struct to encapsulate access to the ModelContext. Let’s move forward by implementing a live value for this dependency. This ensures smooth interaction with the ModelContext across your entire application.

Now, let’s get more specific. You’ve also created tailored dependencies for accessing models, like WorkoutDatabase. This handy tool encapsulates all the CRUD (Create, Read, Update, Delete) operations for the Workout model.

public extension DependencyValues {
var workoutDatabase: WorkoutDatabase {
get { self[WorkoutDatabase.self] }
set { self[WorkoutDatabase.self] = newValue }
}
}

public struct WorkoutDatabase {
public var fetchAll: @Sendable () throws -> [Workout]
public var fetch: @Sendable (FetchDescriptor<Workout>) throws -> [Workout]
public var fetchCount: @Sendable (FetchDescriptor<Workout>) throws -> Int
public var add: @Sendable (Workout) throws -> Void
public var delete: @Sendable (Workout) throws -> Void
public var save: @Sendable () throws -> Void

enum WorkoutError: Error {
case add
case delete
case save
}
}

extension WorkoutDatabase: DependencyKey {
public static let liveValue = Self(
fetchAll: {
// Fetch all workouts from the database
}, fetch: { descriptor in
// Fetch workouts based on descriptor criteria
}, fetchCount: { descriptor in
// Fetch count of workouts based on descriptor criteria
}, add: { model in
// Add a workout to the database
}, delete: { model in
// Delete a workout from the database
}, save: {
// Save changes to the database
}
)
}

You’ve provided a solid foundation with the WorkoutDatabase struct, equipped with properties for CRUD operations related to the Workout model. Remember, the implementation of these operations often involves working with the ModelContext handled by Swift Data. Depending on your project’s unique requirements and Swift Data configuration, you may need to adjust these operations accordingly.

By structuring your dependencies in this way, you’ve achieved a smooth integration between Swift Data and TCA. This ensures that your SwiftUI views can now interact with your app’s data using TCA principles, resulting in a robust and scalable architecture. Well done!

Now, let’s dive deeper into how your WorkoutsListFeature reducer seamlessly incorporates these operations to manage your app’s state:

@Reducer
public struct WorkoutsListFeature {
@ObservableState
public struct State: Equatable {
var workouts: [Workout] = []

// Database ops
fileprivate func fetchWorkouts() -> [Workout] {
@Dependency(\.workoutDatabase.fetch) var fetch
do {
return try fetch(fetchDescriptor)
} catch {
// Handle error
return []
}
}

fileprivate mutating func deleteWorkout(_ workout: Workout) {
@Dependency(\.workoutDatabase.delete) var delete
do {
try delete(workout)
} catch {
// Unable to delete, Handle error
}
}
}

public enum Action {
@available(*, message: "Use with caution as this action is irreversible")
case delete(workout: Workout)
case fetchWorkouts
}

public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .fetchWorkouts:
let fetchResults = state.fetchWorkouts()
state.workouts += fetchResults
return .none

case let .delete(workout):
state.deleteWorkout(workout)
return .none
}
}
}
}

In this section, your reducer is primed to respond to two key actions: fetchWorkouts and delete. Upon triggering the fetchWorkouts action, the reducer springs into action, retrieving workouts from the database and seamlessly updating the state to reflect the changes. Similarly, when the delete action is invoked, the reducer swiftly removes the specified workout from the database and updates the state accordingly.

Now, let’s shift our focus to the WorkoutsListView. Here, you bind the store of WorkoutsListFeature, establishing a direct connection to the underlying state managed by TCA. Then, you gracefully iterate over the workouts in the state, ensuring that each workout is elegantly rendered using WorkoutRowView. This meticulous approach guarantees that your UI remains perfectly synchronized with the state governed by TCA.

struct WorkoutsListView: View {
/// The store
@Bindable var store: StoreOf<WorkoutsListFeature>

var body: some View {
ForEach(store.state.workouts, id: \.id) { workout in
WorkoutRowView(workout: workout)
}
}
}

With this setup, your SwiftUI views seamlessly mirror the changes to the app’s state orchestrated by TCA. This ensures a fluid and intuitive user experience as users engage with your workout tracker app. Kudos on the seamless integration of Swift Data with TCA!

--

--

Responses (2)