Data Flow in SwiftUI: Unidirectional, Async, and Resilient
Design unidirectional, async, and multi-source data flows in SwiftUI that stay predictable, testable, and resilient to failures.
Data Flow in SwiftUI: Unidirectional, Async, and Resilient
Design unidirectional, async, and multi-source data flows in SwiftUI that stay predictable, testable, and resilient to failures.
Photo by Jason Yuen on Unsplash
Github: https://github.com/ReposUniversity/Study/tree/main/DataFlow-Example
TL;DR
- Model your features with explicit actions and state, then drive SwiftUI from a single source of truth.
- Use async/await and Tasks for cancellable, debounced, and concurrent operations without freezing the UI.
- Synchronize network, cache, and local database changes through a dedicated state synchronizer instead of ad-hoc observers.
- Wrap your pipelines in error-aware types that support retries, fallbacks, and stale data handling without scattering error logic in views.
- Connect data flow to navigation so push, sheet, and deep-link flows behave predictably under failure and reloads.
Hook
You probably have a SwiftUI screen that works great — until you add real network calls, background sync, and a couple of “quick” features. Suddenly a user opens a deep link, pulls to refresh, taps back, and your navigation stack forgets where it was. A spinner never stops. An alert pops up twice. Somewhere, state changed in the wrong order and your beautiful SwiftUI code became a race-condition factory.
The problem usually isn’t SwiftUI. It’s data flow.
When state can be mutated from views, services, and ad-hoc callbacks, navigation and UI logic become impossible to reason about — especially once you throw async/await and Combine into the mix. The fix is not another “coordinator” type bolted onto messy state. The fix is a clear, unidirectional data flow: actions in, state out, with well-defined async, sync, and error-handling paths.
In this article, we’ll build exactly that for a user-centric feature and connect it to real SwiftUI views.
Table of Contents
- Why Data Flow Matters in SwiftUI
- Unidirectional Data Flow With Actions and State
- Managing Async Data Flow With Structured Concurrency
- Multi-Source State Synchronization With Combine
- Robust Error Handling Around Your Pipelines
- Rollout Checklist: Bringing It All Together
Overview
Effective data flow is the difference between “it usually works” and “I can predict every state transition in this feature.”
In SwiftUI, views are cheap and reactive. The real complexity lives in how data moves: where it’s stored, who can mutate it, and in which order updates happen when the user taps, scrolls, or navigates through deep links and background refreshes.
A solid data flow should:
- Ensure predictable state updates: every UI change can be traced back to a small set of actions.
- Prevent race conditions: async work completes in a controlled way, even when users spam refresh or back navigation.
- Maintain a single source of truth across views: list, detail, sheet, and full-screen cover all read consistent state instead of re-fetching ad-hoc.
We’ll use that lens across the patterns and code that follow.
Takeaway: Treat data flow as a first-class design problem, not an implementation detail, if you want predictable SwiftUI behavior.
Patterns
For data flow in SwiftUI, three patterns show up repeatedly:
- Unidirectional data flow: views emit actions; a store or view model updates state; views re-render from that state. No view mutates shared state directly.
- Reactive streams with Combine: anything that changes over time (text input, connectivity, background updates) becomes a stream instead of a callback jungle.
- Async coordination with lifecycle awareness: structured concurrency (async/await,
Task,TaskGroup) lets you keep long-running work cancellable and tied to view or feature lifetime.
The JSON you provided already encodes these ideas into concrete types: actions, state containers, asynchronous view models, a state synchronizer, and an error-aware data-flow manager. The rest of this article is about making those pieces work together in a way that survives real-world navigation, backgrounding, and flaky networks.
Pro Tip: Design your data flow first on paper: list the actions, the state, and where async work happens. Then write code to match that diagram.
Takeaway: Pick a small, consistent set of patterns and apply them everywhere — unidirectional flow, reactive streams, and structured async.
Implementing Unidirectional Data Flow
import SwiftUI
import Combine
// Action-based data flow
enum UserAction {
case loadUsers
case searchUsers(String)
case selectUser(UUID)
case refreshUsers
case deleteUser(UUID)
}
// State container
struct UserState {
var users: [User] = []
var filteredUsers: [User] = []
var selectedUser: User?
var searchText: String = ""
var isLoading: Bool = false
var errorMessage: String?
}
// Store managing state transitions
class UserStore: ObservableObject {
@Published private(set) var state = UserState()
private let userService: UserServiceProtocol
private var cancellables = Set<AnyCancellable>()
init(userService: UserServiceProtocol) {
self.userService = userService
setupDataFlow()
}
func dispatch(_ action: UserAction) {
switch action {
case .loadUsers:
loadUsers()
case .searchUsers(let query):
searchUsers(query: query)
case .selectUser(let userId):
selectUser(userId: userId)
case .refreshUsers:
refreshUsers()
case .deleteUser(let userId):
deleteUser(userId: userId)
}
}
private func setupDataFlow() {
// Reactive search filtering
$state
.map(\.searchText)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] searchText in
self?.filterUsers(searchText: searchText)
}
.store(in: &cancellables)
}
private func loadUsers() {
state.isLoading = true
state.errorMessage = nil
userService.fetchUsers()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.state.isLoading = false
if case .failure(let error) = completion {
self?.state.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] users in
self?.state.users = users
self?.filterUsers(searchText: self?.state.searchText ?? "")
}
)
.store(in: &cancellables)
}
private func filterUsers(searchText: String) {
if searchText.isEmpty {
state.filteredUsers = state.users
} else {
state.filteredUsers = state.users.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.email.localizedCaseInsensitiveContains(searchText)
}
}
}
private func selectUser(userId: UUID) {
state.selectedUser = state.users.first { $0.id == userId }
}
private func refreshUsers() {
loadUsers()
}
private func deleteUser(userId: UUID) {
state.users.removeAll { $0.id == userId }
filterUsers(searchText: state.searchText)
if state.selectedUser?.id == userId {
state.selectedUser = nil
}
}
private func searchUsers(query: String) {
state.searchText = query
}
}
Why this design works
- Actions are explicit: every meaningful event — load, search, select, refresh, delete — is represented in
UserAction. That makes logging, analytics, and testing trivial: you just drive the store by dispatching actions. - State is centralized:
UserStateholds all UI-relevant data for the user list and selection. Views become pure functions ofstate. - Store is the only mutator: views cannot directly mutate
state. They calldispatch, which routes to private methods (loadUsers,deleteUser, etc.) that encapsulate side effects and transitions.
The setupDataFlow() method is key: it turns state.searchText into a reactive stream:
- It listens to all state changes.
- It maps to just
searchText. - It debounces to avoid filtering on every keystroke.
- It calls
filterUserson the main queue.
This gives you a powerful pattern: derive new fields (filteredUsers) from existing state (users, searchText) rather than manually keeping them in sync in every action.
Connecting to SwiftUI and navigation
A typical user flow:
UserListViewappears and callsstore.dispatch(.loadUsers).- The user types in a search bar bound to
state.searchTextvia a helper that dispatches.searchUsers(text). - When the user taps a cell, the view dispatches
.selectUser(user.id)and sets a UI-local@Stateor@Bindingto present a sheet or push a detail view. - A deep link that targets a specific user can parse the ID and dispatch
.selectUser(id)before the navigation stack pushes the detail screen.
The trick is that navigation stays a function of state: “route to detail if selectedUser != nil” or “push path contains a UserDestination.detail(id)”. UserStore doesn’t know about views, but it exposes the state they need.
Gotcha: Don’t let views mutate
UserStatedirectly “for convenience.” It will work at first, then make race conditions impossible to debug when async work is added.
Testability
This pattern is easy to test:
- Inject a test
UserServiceProtocolthat returns predefined users or errors. - Dispatch actions in unit tests and assert on
store.stateafter Combine pipelines complete. - Verify debounce logic by controlling the scheduler in tests (e.g., using a test scheduler instead of
DispatchQueue.main).
Takeaway: Model everything as “actions in, state out” and keep the store as the single place that mutates shared feature state.
AsyncFlow
Once your data flow is unidirectional, async work becomes the next pain point:
- Users pull to refresh while an automatic load is running.
- Search queries come in faster than the network can respond.
- Background refresh, manual refresh, and deep-link-triggered fetches all compete.
You want concurrency, but you also want cancellation and predictable ordering. Swift concurrency gives you the right primitives — Task, TaskGroup, Task.sleep, Task.checkCancellation()—but you still need to wrap them in a sensible view model that plays nicely with SwiftUI’s lifecycle.
A good rule:
- Long-running work lives in a view model or store.
- Tasks are cancelled when the owning object deinitializes.
- Navigation and UI decisions depend only on observable state (loading, error, data), never on “Task is still running” flags sprinkled around.
Takeaway: Structured concurrency should live inside your data-flow layer, so SwiftUI views only care about simple published properties.
Managing Async Data Flow
import SwiftUI
import Combine
@MainActor
class AsyncUserViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var error: UserError?
private let userService: UserServiceProtocol
private var loadUsersTask: Task<Void, Never>?
private var searchTask: Task<Void, Never>?
init(userService: UserServiceProtocol) {
self.userService = userService
}
func loadUsers() {
// Cancel previous loading task
loadUsersTask?.cancel()
loadUsersTask = Task {
isLoading = true
error = nil
do {
// Simulate network delay
try await Task.sleep(nanoseconds: 500_000_000)
// Check for cancellation
try Task.checkCancellation()
let fetchedUsers = try await userService.fetchUsers()
// Check for cancellation before updating UI
try Task.checkCancellation()
users = fetchedUsers
} catch is CancellationError {
// Handle cancellation gracefully
print("User loading cancelled")
} catch {
self.error = UserError.loadingFailed(error.localizedDescription)
}
isLoading = false
}
}
func searchUsers(query: String) async {
// Cancel previous search
searchTask?.cancel()
searchTask = Task {
// Debounce search
try await Task.sleep(nanoseconds: 300_000_000)
try Task.checkCancellation()
do {
let results = try await userService.searchUsers(query: query)
try Task.checkCancellation()
users = results
} catch is CancellationError {
print("Search cancelled")
} catch {
self.error = UserError.searchFailed(error.localizedDescription)
}
}
}
func refresh() async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
await self.loadUsers()
}
// Add more concurrent operations
group.addTask {
// Load user preferences
await self.loadUserPreferences()
}
group.addTask {
// Sync analytics
await self.syncAnalytics()
}
}
}
private func loadUserPreferences() async {
// Implementation
}
private func syncAnalytics() async {
// Implementation
}
deinit {
// Cancel all tasks when view model is deallocated
loadUsersTask?.cancel()
searchTask?.cancel()
}
}
enum UserError: Error, LocalizedError {
case loadingFailed(String)
case searchFailed(String)
case networkUnavailable
var errorDescription: String? {
switch self {
case .loadingFailed(let message):
return "Failed to load users: \(message)"
case .searchFailed(let message):
return "Search failed: \(message)"
case .networkUnavailable:
return "Network connection unavailable"
}
}
}
// Usage in SwiftUI view
struct AsyncUserListView: View {
@StateObject private var viewModel: AsyncUserViewModel
@State private var searchText = ""
init(userService: UserServiceProtocol) {
_viewModel = StateObject(wrappedValue: AsyncUserViewModel(userService: userService))
}
var body: some View {
NavigationView {
VStack {
SearchBar(text: $searchText) { query in
Task {
await viewModel.searchUsers(query: query)
}
}
if viewModel.isLoading {
ProgressView("Loading users...")
} else if let error = viewModel.error {
ErrorView(error: error) {
Task {
await viewModel.loadUsers()
}
}
} else {
List(viewModel.users) { user in
UserRowView(user: user)
}
}
}
.navigationTitle("Users")
.refreshable {
await viewModel.refresh()
}
.task {
await viewModel.loadUsers()
}
}
}
}
Key ideas in this async flow
- The view model is
@MainActor, so all published properties are mutated on the main actor, which keeps SwiftUI happy. - Each concern gets its own
Taskreference (loadUsersTask,searchTask), so you can cancel them independently. Task.sleepplusTask.checkCancellation()implement a manual debounce and ensure you don’t flash stale results if the user types quickly.refresh()useswithTaskGroupto run several operations concurrently while keeping their lifetime tied to a single call.
On the view side, AsyncUserListView wires data flow to lifecycle:
.task { await viewModel.loadUsers() }kicks off the first load when the view appears..refreshablemaps pull-to-refresh torefresh().- The search bar triggers
searchUsersin aTask, so the view stays reactive while searches run.
Pro Tip: Prefer a small number of well-named entry points (
loadUsers,refresh,searchUsers) and compose inside them withTaskGroupinstead of exposing many loosely related async methods.
Gotcha: Avoid doing heavy synchronous work inside
Taskbodies on the main actor. Even thoughTask.sleepsuspends, blocking calls will still freeze rendering.
Example navigation flow with async data
Imagine a deep link to /users/123:
- Your app parses the URL and creates
AsyncUserViewModelwithuserService. - You present
AsyncUserListViewinside aNavigationStack. - Once
loadUsers()completes, you can push a detail view by observing publishedusersand matching the target ID. - If the user pulls to refresh while on the detail screen,
refresh()updates shared state and the detail view simply re-renders.
Navigation state is derived from model state, not from the accidental success or failure of a particular network call.
Takeaway: Keep async work in a dedicated view model with explicit cancellation and let the view read only simple, published properties.
StateSync
Real apps rarely read from a single source:
- Network updates from a backend.
- Local database for offline support.
- In-memory cache to speed up UI transitions.
If each layer publishes its own updates and views subscribe directly, you get divergent state: the list shows one thing, the detail screen shows another, and a background sync rewrites both without warning.
A better approach is a state synchronizer:
- Subscribe to each source once.
- Merge and reconcile into a single app-level state.
- Optionally propagate the merged result back to each source (e.g., update cache and local DB after merging network data).
This pattern is especially powerful when combined with navigation: list, search, and detail views all depend on CombinedAppState, which already represents the best possible state across sources.
Takeaway: Treat network, database, and cache as inputs to a single synchronized state rather than competing sources of truth.
Multi-Source State Synchronization
import SwiftUI
import Combine
// Central state synchronizer
class StateSynchronizer: ObservableObject {
@Published var combinedState = CombinedAppState()
private let networkService: NetworkServiceProtocol
private let localDatabase: LocalDatabaseProtocol
private let cacheService: CacheServiceProtocol
private var cancellables = Set<AnyCancellable>()
private let syncQueue = DispatchQueue(label: "state.sync", qos: .userInitiated)
init(
networkService: NetworkServiceProtocol,
localDatabase: LocalDatabaseProtocol,
cacheService: CacheServiceProtocol
) {
self.networkService = networkService
self.localDatabase = localDatabase
self.cacheService = cacheService
setupStateSynchronization()
}
private func setupStateSynchronization() {
// Sync users from multiple sources
Publishers.CombineLatest3(
networkService.userUpdates,
localDatabase.userUpdates,
cacheService.userUpdates
)
.debounce(for: .milliseconds(100), scheduler: syncQueue)
.receive(on: DispatchQueue.main)
.sink { [weak self] networkUsers, localUsers, cachedUsers in
self?.reconcileUserStates(
network: networkUsers,
local: localUsers,
cached: cachedUsers
)
}
.store(in: &cancellables)
// Handle connectivity changes
NotificationCenter.default.publisher(for: .networkConnectivityChanged)
.sink { [weak self] _ in
self?.handleConnectivityChange()
}
.store(in: &cancellables)
}
private func reconcileUserStates(
network: [User],
local: [User],
cached: [User]
) {
var reconciledUsers: [User] = []
// Create a merge strategy based on timestamps and priorities
let allUserIds = Set(network.map(\.id))
.union(Set(local.map(\.id)))
.union(Set(cached.map(\.id)))
for userId in allUserIds {
let networkUser = network.first { $0.id == userId }
let localUser = local.first { $0.id == userId }
let cachedUser = cached.first { $0.id == userId }
// Priority: Network > Local > Cache (if timestamps are recent)
let reconciledUser = selectMostRecentUser(
network: networkUser,
local: localUser,
cached: cachedUser
)
if let user = reconciledUser {
reconciledUsers.append(user)
}
}
combinedState.users = reconciledUsers
// Propagate changes back to data sources
propagateChanges(users: reconciledUsers)
}
private func selectMostRecentUser(
network: User?,
local: User?,
cached: User?
) -> User? {
let candidates = [network, local, cached].compactMap { $0 }
return candidates.max { user1, user2 in
user1.lastModified < user2.lastModified
}
}
private func propagateChanges(users: [User]) {
Task {
// Update cache
try? await cacheService.updateUsers(users)
// Update local database
try? await localDatabase.saveUsers(users)
// Optionally sync back to network if needed
if isConnectedToNetwork {
try? await networkService.syncUsers(users)
}
}
}
private func handleConnectivityChange() {
if isConnectedToNetwork {
// Sync pending changes when connection is restored
Task {
await syncPendingChanges()
}
}
}
private func syncPendingChanges() async {
// Implement sync logic for offline changes
}
private var isConnectedToNetwork: Bool {
// Implementation to check network connectivity
return true
}
}
struct CombinedAppState {
var users: [User] = []
var syncStatus: SyncStatus = .synced
var lastSyncDate: Date?
}
enum SyncStatus {
case syncing
case synced
case error(String)
case offline
}
// Protocol extensions for reactive publishers
extension NetworkServiceProtocol {
var userUpdates: AnyPublisher<[User], Never> {
// Return a publisher that emits user updates
Just([]).eraseToAnyPublisher()
}
}
extension LocalDatabaseProtocol {
var userUpdates: AnyPublisher<[User], Never> {
// Return a publisher that emits local user updates
Just([]).eraseToAnyPublisher()
}
}
extension CacheServiceProtocol {
var userUpdates: AnyPublisher<[User], Never> {
// Return a publisher that emits cached user updates
Just([]).eraseToAnyPublisher()
}
}
// Extensions to User model
extension User {
var lastModified: Date {
// Return the last modification date
Date()
}
}
// Notification extension
extension Notification.Name {
static let networkConnectivityChanged = Notification.Name("networkConnectivityChanged")
}
How this synchronizer behaves
- The synchronizer listens to three publishers: network, local DB, and cache.
- It merges them with
CombineLatest3, debounced on a background queue to avoid recomputing too often. - It reconciles users by ID and picks the most recent one using
lastModified. - It updates
combinedStateand then propagates the merged users back down to cache, local DB, and optionally the network.
This makes CombinedAppState a natural place to hang navigation and feature-level decisions. For example:
- A user list screen can bind directly to
combinedState.users. - A detail screen can derive its model by ID from
combinedState. - A sync indicator (sheet, banner, or full-screen cover during migrations) can read
syncStatus.
Gotcha: Be very clear about timestamp semantics. If
lastModifiedis not comparable across sources (e.g., clock skew), consider explicit versioning or conflict-resolution rules instead of naive max.
Testability
Because StateSynchronizer depends only on protocols, you can:
- Provide controlled publishers in tests that emit specific user snapshots.
- Verify that
combinedState.usersmatches the expected merge order. - Stub the async
updateUsers,saveUsers, andsyncUsersmethods to ensure they’re called with the merged data.
Takeaway: A central state synchronizer lets you model multi-source consistency once and reuse it across every view and navigation flow.
ErrorHandling
Even the cleanest data flow breaks under unreliable networks, schema changes, or partial outages. The worst version of this is when errors leak directly into your views:
- A random
nilcauses a crash. - You lose data when one request out of three fails.
- The only fallback is “show an alert and give up.”
Instead, treat errors as first-class state:
- Wrap your payload in a result type that tracks loading, error, and freshness.
- Centralize retry logic and fallbacks so views don’t need to know how many attempts have already run.
- Provide clear, user-facing messages without exposing internal reasons unless needed.
This also matters for navigation: you don’t want a detail screen to pop or a sheet to dismiss just because the latest refresh failed. It should keep showing the last good data while offering safe recovery paths.
Takeaway: Error handling belongs next to your data flow, not sprinkled across views, to avoid brittle UX under failure.
Robust Error Handling in Data Flow
import SwiftUI
import Combine
// Error handling wrapper
struct DataFlowResult<T> {
let data: T?
let error: DataFlowError?
let isLoading: Bool
let lastSuccessfulUpdate: Date?
var isSuccess: Bool { error == nil && data != nil }
var hasStaleData: Bool {
guard let lastUpdate = lastSuccessfulUpdate else { return false }
return Date().timeIntervalSince(lastUpdate) > 300 // 5 minutes
}
}
enum DataFlowError: Error, Equatable {
case networkError(String)
case parseError(String)
case cacheError(String)
case validationError(String)
var isRecoverable: Bool {
switch self {
case .networkError: return true
case .parseError: return false
case .cacheError: return true
case .validationError: return false
}
}
var userMessage: String {
switch self {
case .networkError:
return "Connection issue. Tap to retry."
case .parseError:
return "Data format error. Please try again later."
case .cacheError:
return "Storage issue. Data may be outdated."
case .validationError(let message):
return message
}
}
}
// Resilient data flow manager
class ResilientDataFlowManager<T>: ObservableObject {
@Published private(set) var result: DataFlowResult<T>
private let dataSource: () -> AnyPublisher<T, Error>
private let fallbackDataSource: (() -> AnyPublisher<T, Error>)?
private let cacheKey: String
private var cancellables = Set<AnyCancellable>()
private var retryAttempts = 0
private let maxRetryAttempts = 3
init(
cacheKey: String,
dataSource: @escaping () -> AnyPublisher<T, Error>,
fallbackDataSource: (() -> AnyPublisher<T, Error>)? = nil
) {
self.cacheKey = cacheKey
self.dataSource = dataSource
self.fallbackDataSource = fallbackDataSource
// Initialize with cached data if available
self.result = DataFlowResult(
data: loadCachedData(),
error: nil,
isLoading: false,
lastSuccessfulUpdate: loadCachedTimestamp()
)
}
func refresh(force: Bool = false) {
guard !result.isLoading || force else { return }
result = DataFlowResult(
data: result.data,
error: nil,
isLoading: true,
lastSuccessfulUpdate: result.lastSuccessfulUpdate
)
dataSource()
.retry(maxRetryAttempts)
.catch { [weak self] error -> AnyPublisher<T, Error> in
// Try fallback data source if available
if let fallback = self?.fallbackDataSource {
return fallback()
.catch { fallbackError in
Fail(error: error) // Return original error if fallback fails
}
.eraseToAnyPublisher()
}
return Fail(error: error).eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.handleError(error)
}
},
receiveValue: { [weak self] data in
self?.handleSuccess(data)
}
)
.store(in: &cancellables)
}
func retryIfPossible() {
guard let error = result.error, error.isRecoverable else { return }
if retryAttempts < maxRetryAttempts {
retryAttempts += 1
refresh(force: true)
}
}
private func handleSuccess(_ data: T) {
retryAttempts = 0
let now = Date()
result = DataFlowResult(
data: data,
error: nil,
isLoading: false,
lastSuccessfulUpdate: now
)
// Cache the successful result
cacheData(data, timestamp: now)
}
private func handleError(_ error: Error) {
let dataFlowError: DataFlowError
if error is URLError {
dataFlowError = .networkError(error.localizedDescription)
} else if error is DecodingError {
dataFlowError = .parseError(error.localizedDescription)
} else {
dataFlowError = .networkError(error.localizedDescription)
}
result = DataFlowResult(
data: result.data, // Keep existing data
error: dataFlowError,
isLoading: false,
lastSuccessfulUpdate: result.lastSuccessfulUpdate
)
}
private func loadCachedData() -> T? {
// Implementation to load cached data
return nil
}
private func loadCachedTimestamp() -> Date? {
// Implementation to load cached timestamp
return nil
}
private func cacheData(_ data: T, timestamp: Date) {
// Implementation to cache data with timestamp
}
}
// SwiftUI view that uses resilient data flow
struct ResilientDataView<T, Content: View>: View {
@StateObject private var dataManager: ResilientDataFlowManager<T>
let content: (T) -> Content
init(
cacheKey: String,
dataSource: @escaping () -> AnyPublisher<T, Error>,
fallbackDataSource: (() -> AnyPublisher<T, Error>)? = nil,
@ViewBuilder content: @escaping (T) -> Content
) {
_dataManager = StateObject(wrappedValue: ResilientDataFlowManager(
cacheKey: cacheKey,
dataSource: dataSource,
fallbackDataSource: fallbackDataSource
))
self.content = content
}
var body: some View {
Group {
if let data = dataManager.result.data {
VStack {
if dataManager.result.hasStaleData {
StaleDataBanner {
dataManager.refresh(force: true)
}
}
content(data)
}
} else if dataManager.result.isLoading {
ProgressView("Loading...")
} else if let error = dataManager.result.error {
ErrorStateView(
error: error,
canRetry: error.isRecoverable
) {
dataManager.retryIfPossible()
}
} else {
Text("No data available")
}
}
.onAppear {
if dataManager.result.data == nil {
dataManager.refresh()
}
}
.refreshable {
dataManager.refresh(force: true)
}
}
}
struct StaleDataBanner: View {
let onRefresh: () -> Void
var body: some View {
HStack {
Image(systemName: "exclamationmark.triangle")
Text("Data may be outdated")
Spacer()
Button("Refresh") {
onRefresh()
}
.buttonStyle(.bordered)
}
.padding()
.background(Color.yellow.opacity(0.1))
.cornerRadius(8)
}
}
struct ErrorStateView: View {
let error: DataFlowError
let canRetry: Bool
let onRetry: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.largeTitle)
.foregroundColor(.red)
Text(error.userMessage)
.multilineTextAlignment(.center)
if canRetry {
Button("Retry") {
onRetry()
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
What this buys you
DataFlowResult<T> is an explicit state machine:
datamay benilor present.errormay benilor present.isLoadingtells you if a request is in flight.lastSuccessfulUpdateplushasStaleDatagives you stale-data awareness.
ResilientDataFlowManager centralizes all the messy parts:
- Retrying a data source with
.retry(maxRetryAttempts). - Falling back to a secondary data source.
- Preserving last good data when new requests fail.
- Caching successful results and timestamps.
ResilientDataView then lets you compose this into your navigation flows:
- A list screen can use
ResilientDataView<[User]>and show either data, a loading indicator, or an error view. - A modal sheet can use the same manager to drive its content and reuse the same retry logic.
This is also friendly for typed navigation. For example, if you use a NavigationStack with a NavigationPath of typed destinations, you can keep the path stable even when ResilientDataFlowManager is in an error state, because the navigation layer doesn’t depend on the success of the latest refresh.
Pro Tip: Don’t expose raw
Errorto your views. Map them to domain errors likeDataFlowErrorwith user messages and recoverability flags.
Takeaway: Wrap your pipelines in a result type that preserves last good data, supports retries, and exposes user-friendly errors.
Rollout Checklist: Implementing This Pattern in a Real App
Putting it all together, here’s a practical sequence to adopt these patterns:
Define feature actions and state
- List every event as an enum case (
UserAction). - Create a single state struct (
UserState,CombinedAppState) per feature or vertical slice.
Introduce a store or view model as the only mutator
- Use
ObservableObjectwith@Publishedstate. - Ensure all views interact via methods like
dispatch(_:)or small async functions.
Move async work behind clean methods
- Implement
loadUsers,searchUsers,refresh, etc. using async/await or Combine. - Add
Taskcancellation andTaskGroupwhere concurrency is needed.
Add multi-source synchronization if you have more than one backend
- Introduce a
StateSynchronizerthat listens to network/cache/DB publishers. - Merge into a single
CombinedAppStateand expose it to views.
Wrap data in error-aware containers
- Use
DataFlowResult<T>for key data sets. - Centralize retry, fallback, and stale detection logic in managers like
ResilientDataFlowManager.
Wire navigation as a function of state
- Derive what to push, present as sheet, or show full-screen based on state (
selectedUser,syncStatus,result.error). - For deep links, parse the destination and dispatch appropriate actions before navigating.
Write tests for state transitions, not just views
- Test stores, view models, synchronizers, and managers with stubbed services.
- Assert on state after actions instead of snapshotting views only.
Proper data flow architecture ensures predictable state updates and prevents race conditions. It enables efficient async coordination, provides robust error handling, and maintains consistency across multiple data sources for reliable user experiences.
Takeaway: Introduce these patterns incrementally — one feature at a time — until unidirectional, async-aware, and resilient data flow is your default.
FAQ
Q1: How do I connect this data flow pattern to deep links in SwiftUI?
Parse the deep link into a domain concept (e.g., user ID), dispatch the corresponding action on your store or view model (such as selecting a user or triggering a load), then let your navigation (e.g., NavigationStack + typed destinations or conditionally presented sheets) respond to the updated state rather than pushing views directly from the deep-link handler.
Q2: Where does NavigationPath fit with unidirectional data flow?
Treat NavigationPath as another piece of state derived from your domain state. For example, when selectedUser is non-nil, map that to a detail destination in the path. The store doesn’t need to know about NavigationPath; it just exposes data that the navigation layer translates.
Q3: How can I restore state after app relaunch using these patterns?
Persist snapshots of your key state containers (UserState, CombinedAppState, or DataFlowResult fields) and reinitialize your stores or view models from those snapshots. Because views render purely from state, restoring that state naturally restores navigation and UI.
Q4: How do I test async data flows that use Tasks and Combine?
For Combine, inject test schedulers and stubbed publishers, then advance time as needed. For async/await, inject protocol-based services that return controlled results, and call async methods from tests using XCTest’s async support, asserting on published state after awaiting completion.
Q5: Should I use a single global store or multiple feature stores?
Prefer multiple feature-level stores. Each feature defines its own actions and state. If you need cross-feature coordination, introduce a higher-level synchronizer (like StateSynchronizer) rather than pushing everything into one giant global store.
Summary
- Unidirectional data flow (actions → store → state → view) makes SwiftUI behavior predictable and easier to debug.
- Async work belongs in view models and managers that support cancellation and structured concurrency, not scattered Tasks in views.
- A central state synchronizer lets you merge network, cache, and local DB into one coherent
CombinedAppState. - Error-aware wrappers like
DataFlowResultandDataFlowErrorkeep last good data visible while providing recoverable error flows. - Navigation: pushes, sheets, and deep links, should depend on state, not on ad-hoc callbacks or the timing of network requests.
If you only remember one thing… Make your SwiftUI views dumb: feed them clear, centralized state and actions, and move all data flow, async work, and error handling into dedicated, testable layers.
If this helped you think more clearly about SwiftUI data flow, consider following and leaving a comment about how you structure state in your own apps.
For deeper dives into real-world SwiftUI architecture patterns, subscribe to the newsletter or check out the companion repo where these patterns are applied across multiple features.
메타데이터
- post_id
- f6429dd0f273
- slug
- data-flow-in-swiftui-unidirectional-async-and-resilient-f6429dd0f273
- url
- https://medium.com/@maatheusgois/data-flow-in-swiftui-unidirectional-async-and-resilient-f6429dd0f273
- canonical_url
- https://medium.com/@maatheusgois/data-flow-in-swiftui-unidirectional-async-and-resilient-f6429dd0f273
- author_url
- https://medium.com/@maatheusgois
- status
- ok
- fetched_at
- 2026-07-20 01:41:37