Skip to content

Functional Core with Unidirectional Flow (Swift)

A SwiftUI guide for coding agents. Also covers middleware pattern swift, reducer state action, store taskgroup side effects, unidirectional flow store, test reducer without mocks.

This follow-up to the functional-core article makes the split opinionated: pure reducers form the functional core, a @MainActor store forms the imperative shell, and Middleware functions carry the side effects. Each layer tests at its own level.

Pure Reducers

A reducer takes state and action and returns new state, nothing else. Tests need no mocks.

typealias Reducer<State, Action> = (State, Action) -> State

let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
  var state = state
  switch action {
  case .start: state.start = .now
  case .finish: state.end = .now
  case .reset:
    state.start = nil
    state.end = nil
  }
  return state
}

func testStart() {
  let state = TimerState(goal: 13 * 3600)
  let newState = timerReducer(state, .start)
  XCTAssertNotNil(newState.start)
}

Middleware for Side Effects

A middleware intercepts an action, runs async work against the injected dependencies, and returns an optional follow-up action to feed back into the store. Mocking the dependencies makes it integration-testable.

typealias Middleware<State, Action, Dependencies> =
  (State, Action, Dependencies) async -> Action?

let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
  switch action {
  case .share:
    guard let start = state.start else {
      return .setSharingStatus(.notShared)
    }
    do {
      try await dependencies.share(start, state.end)
      return .setSharingStatus(.shared)
    } catch {
      return .setSharingStatus(.notShared)
    }
  default:
    return nil
  }
}

The Store Ties It Together

The store reduces the action first, then runs every middleware in a TaskGroup, feeding returned actions back through send. @MainActor guards the state, and the task group gives cooperative cancellation of side effects for free.

@MainActor public final class Store<State, Action, Dependencies>: ObservableObject {
  @Published public private(set) var state: State

  private let reducer: Reducer<State, Action>
  private let dependencies: Dependencies
  private let middlewares: [Middleware<State, Action, Dependencies>]

  public init(
    initialState state: State,
    reducer: @escaping Reducer<State, Action>,
    dependencies: Dependencies,
    middlewares: [Middleware<State, Action, Dependencies>] = []
  ) {
    self.reducer = reducer
    self.state = state
    self.dependencies = dependencies
    self.middlewares = middlewares
  }

  public func send(_ action: Action) async {
    state = reducer(state, action)

    await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
      for middleware in middlewares {
        group.addTask {
          await middleware(state, action, dependencies)
        }
      }

      for await case let action? in group {
        await send(action)
      }
    }
  }
}

A view creates the store with @StateObject and dispatches with Task { await store.send(.start) } from buttons; the state is read-only from outside, so every mutation flows through an action.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-functional-core-imperative-shell-in-swift-unidirectional-flow" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems