Setting up TCA for your project with Tuist

Lee young-jun
5 min readMar 21, 2024

--

Tuist is ready in previous article. Next is TCA. TCA is also one of famous tools.

If you used Redux in React/React-Native, the concept might be easy for you.
Because TCA has Reducer.

Installation

The Installation Guide mentioned Xcode, however we created project with Tuist. Therefore I appended to packages.

let project = Project(
...,
packages: [
...,
.remote(
url: "https://github.com/pointfreeco/swift-composable-architecture",
requirement: .upToNextMinor(from: "1.9.2"))
],
targets: [
.target(
...,
dependencies: [
...,
.package(product: "ComposableArchitecture", type: .runtime)
],

This will install 1.9.2 and upgrade if there is any fix. The installed package name is ComposableArchitecture.

After generating project, many dependency packages also installed.

Trust & Enable

But I couldn’t build due to Swift Macro Policy. They need to Trust and Enable.

There is only errors, so I cleaned the project and opened Xcode.

By clicking the warnings, it was possible to enable macros and the errors have been solved.

Usage

Import

To use TCA, I imported ComposableAchitecture. Where is The? 🤣

Reducer

With @Reducer attribute, we can define reducers.

@Reducer
struct MainReducer {

}

State

Reducer can have the state and we can specify the state type with @ObservableState attribute.

@Reducer
struct MainReducer {
@ObservableState
struct State: Equatable {
var count = 0
}
}

Action

Reducer has also actions declared as an enumeration. These are action type, not action function.

@Reducer
struct MainReducer {
...
enum Action {
case countIncreaseButtonTapped
case countDecreaseButtonTapped
}
}

Reduce

Reduce is the real implementation of actions. If you used react-redux, this is weird, because the redux’s reduce returns a new state.

@Reducer
struct MainReducer {
...
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .countIncreaseButtonTapped:
state.count += 1
return .none
case .countDecreaseButtonTapped:
state.count -= 1
return .none
}
}
}
}

run

If you want to run something different task, not to change a state. use .run instead of .none.

@Reducer
struct MainReducer {
enum Action {
...
case uploadCount
}

var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
...
case .uploadCount:
return .run { send in
// await task...
}
}
}
}
}

send

In other case, we need to update state when finished asynchronous tasks. By call send method in .run block, we can update another action.

@Reducer
struct MainReducer {
@ObservableState
struct State: Equatable {
...
var uploadingResult = ""
}

enum Action {
...
case uploadCount
case updateUploadingResult(result: String)
}

var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
...
case .uploadCount:
return .run { send in
// await task...
await send(.updateUploadingResult(result: "completed"))
}
case .updateUploadingResult(result: let result):
state.uploadingResult = result
return .none
}
}
}
}

SwiftUI

state

To use the state in SwiftUI, create state instance.

let state = Store(initialState: MainReducer.State()) {
MainReducer()
}

To inject store, specify store type with StateOf.

let state : StoreOf<MainReducer.State>

action

So how to call actions? Just call send like .run block.

state.send(.countIncreaseButtonTapped)

Full Source

import SwiftUI
import SwiftData
import ComposableArchitecture

@Reducer
struct MainReducer {
@ObservableState
struct State: Equatable {
var count = 0
var uploadingResult = ""
}

enum Action {
case countIncreaseButtonTapped
case countDecreaseButtonTapped
case uploadCount
case updateUploadingResult(result: String)
}

var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .countIncreaseButtonTapped:
state.count += 1
return .none
case .countDecreaseButtonTapped:
state.count -= 1
return .none
case .uploadCount:
return .run { send in
// await task...
await send(.updateUploadingResult(result: "completed"))
}
case .updateUploadingResult(result: let result):
state.uploadingResult = result
return .none
}
}
}
}

struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
let state = Store(initialState: MainReducer.State()) {
MainReducer()
}

var body: some View {
NavigationSplitView {
Text("\(state.count)")
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}

private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)

state.send(.countIncreaseButtonTapped)
}
}

private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
state.send(.countDecreaseButtonTapped)
}
}
}
}

#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}

If you found this post helpful, please give it a round of applause 👏. Explore more iOS-related content in my other posts.

For additional insights and updates, check out my LinkedIn profile. Thank you for your support!

Troubleshootings

target CasePathsMacros must be enabled

Swift Macros are required to be trusted

Clean and reload and Click warning

References

--

--

No responses yet