Navigation in TCA — The Modern Architecture (with UnitTests)

Lakshminaidu C
3 min readJun 6, 2024

--

NavigationStack(
path: // Store focused on StackState and StackAction
) {
// Root view of the navigation stack
} destination: { store in
// A view for each case of the Path.State enum
}
Navigation

Hi let’s learn How navigation works in TCA, and it is testable too.

Let jump in to code.

TCA uses stack-based navigation. we will be creating destinations Enum cases with child reducers and using path can mange navigation to any screen with sending event from view.

Here is the LoginReducer and we have two Destinations called Signup and Forgot password and its child reducers, don’t forget .equatable

@Reducer
struct LoginReducer {
// child reducers
@Reducer(state: .equatable)
enum Destination {
case forgotPassword(ForgotPasswordReducer)
case signup(SignupReducer)
}

@ObservableState
struct State: Equatable {
}
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
return .none
}
}
}

// child reducers
@Reducer
struct ForgotPasswordReducer {
@ObservableState
struct State: Equatable {
}
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
return .none
}
}
}
}

@Reducer
struct SignupReducer {
@ObservableState
struct State: Equatable {
}
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
return .none
}
}
}
}

Let’s create path and events in LoginReducer, The path is of type StackState in state and StackAction in Action Enum of Destination Type

@Reducer
struct LoginReducer {
@ObservableState
struct State: Equatable {
// ...
var path = StackState<Destination.State>()
// ...
}
enum Action: BindableAction {
// ....
case path(StackAction<Destination.State, Destination.Action>) // navigation
case showForgoPassword
case showSignup
// ....
}
}

Let’s implement LoginReducer body to handle events from View, as view will send actions to reducers. and .forEach(\.path, action: \.path) is important

LoginReducer {
//.....
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .showForgoPassword: // to show password screen append the .forgotPassword state
// Navigation start here
state.path.append(.forgotPassword(ForgotPasswordReducer.State()))
return .none
case .showSignup: // to show signup screen append the .signup state
// Navigation start here
state.path.append(.signup(SignupReducer.State()))
return .none
case .path(.popFrom(id: _)):
state.path.removeAll() // clear navigation stack
case .path(_):
return .none
case .binding(_): // to observe state value changes
return .none
}
}
}
.forEach(\.path, action: \.path) // imaportnant
}
}

Let’s create views

struct LoginView: View {
@State var store: StoreOf<LoginReducer>
var body: some View {
NavigationStack(
path: $store.scope(state: \.path, action: \.path) // path here
) {
VStack(spacing: 16) {
// .....
HStack {
Spacer()
Text("Forgot password")
.foregroundStyle(.secondary)
.onTapGesture {
self.store.send(.showForgoPassword) // show password here
}
}
Text("Or")
.foregroundStyle(.secondary)
.font(.footnote)
Button(action: {
store.send(.showSignup)
}, title: "Signup")
}
.padding()
} destination: { state in
switch state.case {
case .forgotPassword(let store):
ForgotPasswordView(store: store)
case .signup(let store):
SignupView(store: store)
}
}
}
}

#Preview {
LoginView(store: Store(initialState: LoginReducer.State(), reducer: {
LoginReducer()
}))
}

Let’s create the Other views

struct SignupView: View {
@Bindable var store: StoreOf<SignupReducer>
var body: some View {
Text("Hello this is Signup")
}
}

struct ForgotPasswordView: View {
@Bindable var store: StoreOf<ForgotPasswordReducer>
var body: some View {
Text("Hello this is ForgotPassword")
}
}

#Preview {
ForgotPasswordView(store: Store(initialState: ForgotPasswordReducer.State(), reducer: {
ForgotPasswordReducer()
}))
}
#Preview {
SignupView(store: Store(initialState: SignupReducer.State(), reducer: {
SignupReducer()
}))
}

Let’s Test Navigation, In TCA all test cases should be on @MainActor, Need to create TestStore instance.

import ComposableArchitecture

final class LoginReducerTests: XCTestCase {
@MainActor
func test_signup_navigation() async {
let store = TestStore(initialState: LoginReducer.State()) {
LoginReducer()
}
// show signup
await store.send(.showSignup) {
$0.path[id: 0] = .signup(SignupReducer.State())
}
}

@MainActor
func test_forgotPassword_navigation() async {
let store = TestStore(initialState: LoginReducer.State()) {
LoginReducer()
}
// show forgotPassword
await store.send(.showForgoPassword) {
$0.path[id: 0] = .forgotPassword(ForgotPasswordReducer.State())
}
}
}

Thats it. It is as easy. Enjoy coding in TCA for more info regarding TCA check here Pointfree
For more features check out the source code here TCANavigation like

  • Api calls in TCA and Testing Api calls
  • Passing child events to parents (ex: events from SignupReduder to Login)
  • Managing AppRoot

Thanks for reading. Hope you like this article, please share if it finds useful. Please follow me Lakshminaidu C for more updates on TCA.

--

--