Unidirectional Data Flow in SwiftUI Apps
SwiftUI already feels unidirectional at the view level.
State changes. The body is recalculated. The view renders the new state.
That part is beautiful.
But a SwiftUI app can still become architecturally bidirectional. A View can call a repository. A ViewModel can call a use case, interpret domain errors, track analytics, update state, and navigate to the next screen. A feature factory can quietly become the place where routing decisions live.
When that happens, the UI may still render declaratively, but the feature is no longer easy to reason about.
The important question is not whether SwiftUI updates views from state. It does.
The important question is whether the feature has a clear direction of control.
#The Shape I Prefer
For non-trivial features, I like this flow:
View intent
-> composed action
-> use case
-> use case output
-> ViewModel state
-> SwiftUI view
Navigation can follow a related but separate path:
ViewModel screen event
-> flow or parent feature
-> NavigationStack path
The names matter here.
A use case output is about the result of an application operation. It might say that login succeeded, login failed, products were loaded, or the cart could not be synced.
A screen event is about something the UI finished doing. It might say the login screen completed or the user selected a product.
Keeping those separate avoids a common confusion: domain results are not navigation commands.
#A Bidirectional SwiftUI Feature
This version is common:
@MainActor
final class LoginViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let loginUseCase: LoginUseCase
private let analytics: AnalyticsTracking
private let router: AppRouter
init(
loginUseCase: LoginUseCase,
analytics: AnalyticsTracking,
router: AppRouter
) {
self.loginUseCase = loginUseCase
self.analytics = analytics
self.router = router
}
func submit(email: String, password: String) async {
isLoading = true
do {
try await loginUseCase.login(
email: email,
password: password
)
analytics.track("login_succeeded")
router.showHome()
} catch {
analytics.track("login_failed")
errorMessage = "Could not log in."
}
isLoading = false
}
}
This is not awful in a tiny app. It is direct. You can open one file and see what happens.
But as the feature grows, the ViewModel becomes the meeting point for application work, analytics policy, routing, error mapping, loading state, and form state. The dependencies point both ways: the ViewModel triggers the use case, handles the result, talks to infrastructure, and decides navigation.
That is a lot of authority for something whose main job should be preparing state for a SwiftUI view.
#Split the Operation From the State Mapping
A use case can own the login operation and report meaningful outcomes.
@MainActor
protocol LoginUseCaseOutput {
func loginDidStart()
func loginDidSucceed()
func loginDidFail(_ error: LoginError)
}
struct LoginUseCase {
let authenticator: Authenticating
let sessionStore: SessionStoring
let output: LoginUseCaseOutput
func execute(email: String, password: String) async {
await output.loginDidStart()
do {
let session = try await authenticator.login(
email: email,
password: password
)
await sessionStore.save(session)
await output.loginDidSucceed()
} catch {
await output.loginDidFail(.invalidCredentials)
}
}
}
Now the ViewModel can map those outcomes into renderable state.
@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
var onEvent: (LoginScreenEvent) -> Void = { _ in }
func loginDidStart() {
isLoading = true
errorMessage = nil
}
func loginDidSucceed() {
isLoading = false
onEvent(.loginCompleted)
}
func loginDidFail(_ error: LoginError) {
isLoading = false
errorMessage = "Could not log in."
}
}
enum LoginScreenEvent {
case loginCompleted
}
The ViewModel does not know how authentication works. It does not know where sessions are stored. It does not know where the app goes after login. It knows how to turn login outcomes into state the view can render.
That is a much smaller job.
#The View Sends Intents
The SwiftUI view should not build the use case either. It can render state and expose user intent.
struct LoginScreen: View {
@StateObject private var viewModel: LoginViewModel
let onSubmit: (String, String) async -> Void
@State private var email = ""
@State private var password = ""
init(
viewModel: LoginViewModel,
onSubmit: @escaping (String, String) async -> Void
) {
_viewModel = StateObject(wrappedValue: viewModel)
self.onSubmit = onSubmit
}
var body: some View {
Form {
TextField("Email", text: $email)
SecureField("Password", text: $password)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
}
Button("Log in") {
Task {
await onSubmit(email, password)
}
}
.disabled(viewModel.isLoading)
}
}
}
The view does not know whether login is remote, local, mocked, decorated, retried, tracked, or cached.
It only says: the user submitted the form.
#The Factory Wires, It Does Not Decide
The feature factory can assemble the ViewModel, use case, and intent closure.
struct LoginFeatureFactory {
let authenticator: Authenticating
let sessionStore: SessionStoring
func make(
onEvent: @escaping (LoginScreenEvent) -> Void
) -> LoginScreen {
let viewModel = LoginViewModel()
viewModel.onEvent = onEvent
let useCase = LoginUseCase(
authenticator: authenticator,
sessionStore: sessionStore,
output: viewModel
)
return LoginScreen(
viewModel: viewModel,
onSubmit: useCase.execute
)
}
}
This is construction code. It creates objects and connects them.
It does not decide that login completion means "show home." That decision belongs to the flow or parent feature.
#The Flow Owns Navigation
In SwiftUI, a flow can own a NavigationPath and translate screen events into routes.
@MainActor
final class AppFlow: ObservableObject {
enum Route: Hashable {
case home
}
@Published var path = NavigationPath()
private let loginFactory: LoginFeatureFactory
init(loginFactory: LoginFeatureFactory) {
self.loginFactory = loginFactory
}
func makeLoginScreen() -> LoginScreen {
loginFactory.make(onEvent: handle)
}
private func handle(_ event: LoginScreenEvent) {
switch event {
case .loginCompleted:
path.append(Route.home)
}
}
}
The root SwiftUI view can render the flow:
struct AppFlowView: View {
@StateObject private var flow: AppFlow
init(flow: AppFlow) {
_flow = StateObject(wrappedValue: flow)
}
var body: some View {
NavigationStack(path: $flow.path) {
flow.makeLoginScreen()
.navigationDestination(for: AppFlow.Route.self) { route in
switch route {
case .home:
HomeScreen()
}
}
}
}
}
The login screen does not know the app has a home screen. The ViewModel does not know about NavigationPath. The factory does not interpret events. The flow owns the journey.
That separation is especially useful when the same screen appears in different contexts. Login completion might open home in one flow, dismiss a sheet in another, or continue checkout in a third.
#Where Analytics Belongs
Analytics is a good test for whether your flow is staying clean.
You can put analytics in the ViewModel:
func loginDidSucceed() {
analytics.track("login_succeeded")
isLoading = false
onEvent(.loginCompleted)
}
Sometimes that is acceptable. But it makes the ViewModel responsible for tracking policy.
If tracking belongs to the operation, decorate the use case output:
final class AnalyticsLoginOutputDecorator: LoginUseCaseOutput {
private let decoratee: LoginUseCaseOutput
private let analytics: AnalyticsTracking
init(
decoratee: LoginUseCaseOutput,
analytics: AnalyticsTracking
) {
self.decoratee = decoratee
self.analytics = analytics
}
func loginDidStart() {
decoratee.loginDidStart()
}
func loginDidSucceed() {
analytics.track("login_succeeded")
decoratee.loginDidSucceed()
}
func loginDidFail(_ error: LoginError) {
analytics.track("login_failed")
decoratee.loginDidFail(error)
}
}
Then the factory composes it:
let output = AnalyticsLoginOutputDecorator(
decoratee: viewModel,
analytics: analytics
)
let useCase = LoginUseCase(
authenticator: authenticator,
sessionStore: sessionStore,
output: output
)
The ViewModel still maps state. The decorator adds tracking. The use case still reports outcomes. No type needs to know everything.
#This Is Not About Never Calling Methods
Unidirectional flow does not mean no object can ever call another object.
The view calls an intent closure. The closure calls the use case. The use case calls its output. The output implementation updates state. SwiftUI renders that state.
The important part is that each call points toward a narrower responsibility.
What I try to avoid is a cycle of knowledge:
ViewModel knows the use case
ViewModel knows the router
ViewModel knows analytics
ViewModel knows business error policy
ViewModel knows display state
That is not a flow. That is a pile.
#When This Is Too Much
For a tiny screen, a direct ViewModel call to a use case can be fine.
func submit() async {
await loginUseCase.execute()
}
Architecture is not a contest to create the most arrows.
The split starts to pay for itself when:
- the operation has multiple meaningful outcomes
- several teams touch nearby code
- navigation differs by entry point
- analytics or logging should be composed without polluting state mapping
- previews and tests need cheap substitutes
- the ViewModel is becoming the feature instead of presenting the feature
That is the same tradeoff discussed in When Is Architecture Actually Overkill?. The goal is not more layers. The goal is cheaper change.
#What You Should Remember
SwiftUI gives you declarative rendering, but it does not automatically give you clean application flow.
For larger features, keep the direction explicit:
View intent -> use case -> output -> ViewModel state -> View
Use screen events for navigation decisions, and let flows or parent features interpret those events.
The ViewModel should prepare state. The use case should perform the operation. The factory should wire dependencies. The flow should own the journey.
Read next
Unidirectional Data Flow in iOS MVP and Clean Architecture
Learn unidirectional data flow in iOS architecture and how it clarifies MVP, use cases, presenters, and domain outputs.