Skip to content

Instantly share code, notes, and snippets.

@michaelkariv
Created July 16, 2025 16:02
Show Gist options
  • Save michaelkariv/cfa6a41e60f2984b762007b7ff3e5acd to your computer and use it in GitHub Desktop.
Save michaelkariv/cfa6a41e60f2984b762007b7ff3e5acd to your computer and use it in GitHub Desktop.
SwiftUI+The Composable Architecture - should Every View, even small, use TCA @Reducer Feature

Should Every Navigation Screen, however small, use TCA @Reducer

  • A total noob question, first project in iOS / Swift/SwiftUI.
  • Should I use TCA Features for all Views? Even small ones.

A simple contrived example

Main View (ContentView) has a customizable background color. It has a button that when tapped, opens a chooser bottom sheet. The chooser offers two options - warm colors and cold colors Each option opens a separate color picker with two sample colors.

There 4 views

  • main view (ContentView)
  • choser view with just two buttons, showin in a .sheet of the ContentView
  • two pickers - separate view but basically identical

There are 2 or 3 Features (@Reducer)

  • one for the ContentView
  • one for the Color picker views
  • the focal point of my question - should the simple bottom sheet that just a chooser between two options have its own TCA Feature for state?

I have two nearly identical files

ContentView.swift ContentViewWithTCA.swift

The only meaningful difference is that the chooser does not have TCA for its trivail state management.

Which one is the better way? Any general advice regarding TCA coverage?

//
// ContentView.swift
// DummySelector
//
// Created by makmini kariv on 15/07/2025.
//
/// Main View (ContentView) has a customizable background color.
/// It has a button that when tapped, opens a chooser bottom sheet.
/// The chooser offers two options - warm colors and cold colors
/// Each option opens a separate color picker with two sample colors
/// The point of this contrived example to see if the chooser sheet should be using its own TCA @Reducer Feature or not
import ComposableArchitecture
import Foundation
import SwiftUI
import Tagged
enum PickerOption {
case warm
case cold
}
// MARK: - ChoosePickerFeature - REMOVED
// This feature is no longer needed since we're using a plain SwiftUI view
// MARK: - ColorFeature
@Reducer
struct ColorFeature {
// MARK: - Destination Reducer
@Reducer
enum Destination {
case warmColor(ColorCreateFeature)
case coldColor(ColorCreateFeature)
// REMOVED: case choosePicker(ChoosePickerFeature)
}
// MARK: - State
@ObservableState
struct State: Equatable {
var color: Color = .orange
@Presents var destination: Destination.State?
var isShowingPicker = false // Add this to control picker presentation
}
// MARK: - Action
enum Action {
case setColor(Color)
case chooseButtonTapped
case pickerOptionSelected(PickerOption) // Add this action
case dismissPicker // Add this action
case destination(PresentationAction<Destination.Action>)
}
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .setColor(let color):
state.color = color
return .none
case let .destination(.presented(.coldColor(.delegate(.save(color))))):
state.color = color
return .none
case let .destination(.presented(.warmColor(.delegate(.save(color))))):
state.color = color
return .none
case .chooseButtonTapped:
state.isShowingPicker = true
return .none
case .dismissPicker:
state.isShowingPicker = false
return .none
case let .pickerOptionSelected(option):
// Dismiss picker and present the appropriate color sheet
state.isShowingPicker = false
if option == .cold {
state.destination = .coldColor(ColorCreateFeature.State(color: state.color))
} else if option == .warm {
state.destination = .warmColor(ColorCreateFeature.State(color: state.color))
} else {
print("ERROR: option is unknown")
}
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination)
}
}
// MARK: - ColorCreateFeature
@Reducer
struct ColorCreateFeature {
// MARK: - State
@ObservableState
struct State: Equatable {
var color: Color = .orange
}
// MARK: - Action
enum Action {
case cancelButtonTapped
case saveButtonTapped
case setColor(Color)
case delegate(Delegate)
enum Delegate: Equatable {
case save(Color)
}
}
// MARK: - dependency dismiss
@Dependency(\.dismiss) var dismiss
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped:
return .run { _ in await self.dismiss() }
case .saveButtonTapped:
return .run { [color = state.color] send in
await send(.delegate(.save(color)))
await self.dismiss()
}
case .setColor(let color):
state.color = color
return .none
default:
return .none
}
}
}
}
// MARK: - Color warmer View
struct ColorWarmView: View {
@Bindable var store: StoreOf<ColorCreateFeature>
var body: some View {
NavigationView {
VStack {
Button("red") { store.send(.setColor(.red)) }
Button("yellow") { store.send(.setColor(.yellow)) }
Button("Cancel") { store.send(.cancelButtonTapped) }
Button("Save") { store.send(.saveButtonTapped) }
store.color.frame(width: 50, height: 50)
}
}
}
}
// MARK: - Color cold View
struct ColorColdView: View {
@Bindable var store: StoreOf<ColorCreateFeature>
var body: some View {
NavigationView {
VStack {
Button("green") { store.send(.setColor(.green)) }
Button("cyan") { store.send(.setColor(.cyan)) }
Button("Cancel") { store.send(.cancelButtonTapped) }
Button("Save") { store.send(.saveButtonTapped) }
store.color.frame(width: 50, height: 50)
}
}
}
}
// MARK: - Picker Chooser View (Plain SwiftUI - No TCA)
struct PickerChooserView: View {
let onOptionSelected: (PickerOption) -> Void
var body: some View {
NavigationView {
VStack {
Button("Warm") {
onOptionSelected(.warm)
}
Button("Cold") {
onOptionSelected(.cold)
}
}
}
}
}
// MARK: - Content (Main) View
struct ContentView: View {
@Bindable var store: StoreOf<ColorFeature>
var body: some View {
NavigationStack {
VStack {
Button {
store.send(.chooseButtonTapped)
} label: {
Label("Choose Cold or Warm", systemImage: "plus")
.padding().background(.black).foregroundColor(.white)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(store.color)
}
.sheet(
isPresented: Binding(
get: { store.isShowingPicker },
set: { _ in store.send(.dismissPicker) }
)
) {
NavigationStack {
PickerChooserView { option in
store.send(.pickerOptionSelected(option))
}
}
.presentationDetents([.medium])
}
.sheet(item: $store.scope(state: \.destination?.warmColor, action: \.destination.warmColor)) { store in
NavigationStack { ColorWarmView(store: store) }
}
.sheet(item: $store.scope(state: \.destination?.coldColor, action: \.destination.coldColor)) { store in
NavigationStack { ColorColdView(store: store) }
}
}
}
// MARK: - Fix Destination to be Equatable
extension ColorFeature.Destination.State: Equatable {}
// MARK: - Preview
#Preview("Content View") {
ContentView(
store: Store(initialState: ColorFeature.State(color: .orange)) {
ColorFeature()
}
)
}
#Preview("Color warmer") {
ColorWarmView(
store: Store(initialState: ColorCreateFeature.State(color: .orange)) {
ColorCreateFeature()
}
)
}
//
//  ContentView.swift
//  DummySelector
//
//  Created by makmini kariv on 15/07/2025.
//
/// Main View (ContentView) has a customizable background color.
/// It has a button that when tapped, opens a chooser bottom sheet.
/// The chooser offers two options - warm colors and cold colors
/// Each option opens a separate color picker with two sample colors
/// The point of this contrived example to see if the chooser sheet should be using its own TCA @Reducer Feature or not
import ComposableArchitecture
import Foundation
import SwiftUI
import Tagged
enum PickerOption {
case warm
case cold
}
// MARK: - ChoosePickerFeature
@Reducer
struct ChoosePickerFeature {
// MARK: - State
@ObservableState
struct State: Equatable {
var option: PickerOption?
}
// MARK: - Action
enum Action {
case warmOptionTapped
case coldOptionTapped
case delegate(Delegate)
enum Delegate: Equatable {
case choose(PickerOption)
}
}
// MARK: - dependency dismiss (Removed as parent will handle dismissal)
// @Dependency(\.dismiss) var dismiss // This dependency is no longer needed here
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .coldOptionTapped:
state.option = .cold
return .run { send in
await send(.delegate(.choose(.cold)))
// IMPORTANT CHANGE: Removed `await self.dismiss()`
// The parent `ColorFeature` will now handle dismissing this sheet
// and presenting the next one.
}
case .warmOptionTapped:
state.option = .warm
return .run { send in
await send(.delegate(.choose(.warm)))
// IMPORTANT CHANGE: Removed `await self.dismiss()`
// The parent `ColorFeature` will now handle dismissing this sheet
// and presenting the next one.
}
case .delegate:
// Delegate actions are sent to the parent, no local effect needed here.
return .none
}
}
}
}
// MARK: - ColorFeature
@Reducer
struct ColorFeature {
// MARK: - Destination Reducer
@Reducer
enum Destination {
case warmColor(ColorCreateFeature)
case coldColor(ColorCreateFeature)
case choosePicker(ChoosePickerFeature)
}
// MARK: - State
@ObservableState
struct State: Equatable {
var color: Color = .orange
@Presents var destination: Destination.State?
}
// MARK: - Action
enum Action {
case setColor(Color)
case chooseButtonTapped
case destination(PresentationAction<Destination.Action>) // tree navigation
}
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .setColor(let color):
state.color = color
return .none
case let .destination(.presented(.coldColor(.delegate(.save(color))))):
state.color = color
return .none
case let .destination(.presented(.warmColor(.delegate(.save(color))))):
state.color = color
return .none
case .chooseButtonTapped:
state.destination = .choosePicker(ChoosePickerFeature.State())
return .none
case let .destination(.presented(.choosePicker(.delegate(.choose(option))))):
// IMPORTANT CHANGE:
// When the delegate action from `choosePicker` is received,
// we directly set the `destination` to the next desired sheet.
// SwiftUI will see this change and transition from the `choosePicker` sheet
// to the new `warmColor` or `coldColor` sheet seamlessly,
// without briefly showing the `ContentView` in between.
if option == .cold {
state.destination = .coldColor(ColorCreateFeature.State(color: state.color))
} else if option == .warm {
state.destination = .warmColor(ColorCreateFeature.State(color: state.color))
} else {
print("ERROR: option is unknown")
}
return .none // No additional side effect needed, state is updated.
case .destination:
// This case handles all other `PresentationAction`s, including dismissals
// of the child sheets when their state becomes nil (e.g., when ColorCreateFeature calls dismiss).
return .none
}
}
// MARK: - Destination actions pass through
.ifLet(\.$destination, action: \.destination)
}
}
// MARK: - ColorCreateFeature (No changes needed here)
@Reducer
struct ColorCreateFeature {
// MARK: - State
@ObservableState
struct State: Equatable {
var color: Color = .orange
}
// MARK: - Action
enum Action {
case cancelButtonTapped
case saveButtonTapped
case setColor(Color)
case delegate(Delegate)
enum Delegate: Equatable {
case save(Color)
}
}
// MARK: - dependency dismiss
@Dependency(\.dismiss) var dismiss
// MARK: - Reducer
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .cancelButtonTapped: return .run { _ in await self.dismiss() }
case .saveButtonTapped:
return .run { [color = state.color] send in
await send(.delegate(.save(color)))
await self.dismiss()
}
case .setColor(let color):
state.color = color
return .none
default: return .none
}
}
}
}
// MARK: - Color warmer View (No changes needed here)
struct ColorWarmView: View {
@Bindable var store: StoreOf<ColorCreateFeature>
var body: some View {
NavigationView {
VStack {
Button("red") { store.send(.setColor(.red)) }
Button("yellow") { store.send(.setColor(.yellow)) }
Button("Cancel") { store.send(.cancelButtonTapped) }
Button("Save") { store.send(.saveButtonTapped) }
store.color.frame(width: 50, height: 50)
}
}
}
}
// MARK: - Color cold View (No changes needed here)
struct ColorColdView: View {
@Bindable var store: StoreOf<ColorCreateFeature>
var body: some View {
NavigationView {
VStack {
Button("green") { store.send(.setColor(.green)) }
Button("cyan") { store.send(.setColor(.cyan)) }
Button("Cancel") { store.send(.cancelButtonTapped) }
Button("Save") { store.send(.saveButtonTapped) }
store.color.frame(width: 50, height: 50)
}
}
}
}
// MARK: - Picker Chooser View (No changes needed here, it correctly uses @Bindable)
struct PickerChooserView: View {
@Bindable var store: StoreOf<ChoosePickerFeature>
var body: some View {
NavigationView {
VStack {
Button("Warm") { store.send(.warmOptionTapped) }
Button("Cold") { store.send(.coldOptionTapped) }
}
}
}
}
// MARK: - Content (Main) View (No changes needed here, the logic is in the reducer)
struct ContentView: View {
@Bindable var store: StoreOf<ColorFeature>
var body: some View {
NavigationStack {
VStack {
Button {
store.send(.chooseButtonTapped)
} label: {
Label("Choose Cold or Warm", systemImage: "plus")
.padding().background(.black).foregroundColor(.white)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(store.color)
}
// These sheets will now react to the `destination` state updates
// managed by the `ColorFeature` reducer.
.sheet(item: $store.scope(state: \.destination?.choosePicker, action: \.destination.choosePicker)) { store in
NavigationStack { PickerChooserView(store: store) }
.presentationDetents([.medium])
}
.sheet(item: $store.scope(state: \.destination?.warmColor, action: \.destination.warmColor)) { store in
NavigationStack { ColorWarmView(store: store) }
}
.sheet(item: $store.scope(state: \.destination?.coldColor, action: \.destination.coldColor)) { store in
NavigationStack { ColorColdView(store: store) }
}
}
}
// MARK: - Fix Destiantion to be Equatable
extension ColorFeature.Destination.State: Equatable {}
// MARK: - Preview
#Preview("Content View") {
ContentView(
store: Store(initialState: ColorFeature.State(color: .orange)) {
ColorFeature()
}
)
}
#Preview("Color warmer") {
ColorWarmView(
store: Store(initialState: ColorCreateFeature.State(color: .orange)) {
ColorCreateFeature()
}
)
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment