SharedState<T> provides automatic state synchronization between iOS and watchOS using applicationContext. This guide explains how to use it effectively.
Shared state is useful for:
- User preferences and settings
- App configuration
- Session data (current workout, active timer, etc.)
- Any data that should be the same on both devices
Key features:
- Automatic sync: Changes are automatically sent to the counterpart
- @Observable: Uses Swift's Observation framework for seamless nested observation
- Persistence: Survives app termination (via applicationContext)
- SwiftUI ready: Works with both
@Observableview models and legacyObservableObject - Simple semantics: Last-write-wins, no versioning complexity
Your state type must conform to Codable, Sendable, and Equatable:
struct AppSettings: Codable, Sendable, Equatable {
var theme: Theme = .system
var notificationsEnabled: Bool = true
var dailyGoal: Int = 10000
enum Theme: String, Codable, Sendable {
case light, dark, system
}
}@MainActor
class SettingsManager: ObservableObject {
let settings: SharedState<AppSettings>
init(connection: WatchConnection) {
settings = SharedState(
initialValue: AppSettings(),
connection: connection
)
}
}// Read current value
let currentTheme = settingsManager.settings.value.theme
// Update
try settingsManager.settings.update(
AppSettings(
theme: .dark,
notificationsEnabled: true,
dailyGoal: 12000
)
)Changes to SharedState.value are automatically observable via Swift's Observation framework:
// SwiftUI - automatic observation
struct SettingsView: View {
@ObservedObject var manager: SettingsManager
var body: some View {
Toggle("Notifications", isOn: Binding(
get: { manager.settings.value.notificationsEnabled },
set: { newValue in
var updated = manager.settings.value
updated.notificationsEnabled = newValue
try? manager.settings.update(updated)
}
))
}
}SharedState uses applicationContext for synchronization:
- When you call
update(), the new value is stored locally - The value is encoded and sent via
updateApplicationContext - The counterpart device receives it via the WCSession delegate
- Both devices now have the same value
Important: Only the latest value is synced. If you update multiple times before the counterpart receives an update, only the final value is delivered. This is WCSession's built-in behavior.
SharedState uses @Observable, so it works seamlessly when embedded in other @Observable view models - no forwarding or binding required:
@Observable @MainActor
final class SettingsViewModel {
private let sharedState: SharedState<AppSettings>
// Just access properties directly - observation is automatic
var theme: AppSettings.Theme { sharedState.value.theme }
var notificationsEnabled: Bool { sharedState.value.notificationsEnabled }
var dailyGoal: Int { sharedState.value.dailyGoal }
init(sharedState: SharedState<AppSettings>) {
self.sharedState = sharedState
}
func updateTheme(_ theme: AppSettings.Theme) {
var settings = sharedState.value
settings.theme = theme
try? sharedState.update(settings)
}
}
struct SettingsView: View {
@State private var viewModel: SettingsViewModel
var body: some View {
Form {
Picker("Theme", selection: Binding(
get: { viewModel.theme },
set: { viewModel.updateTheme($0) }
)) {
ForEach(AppSettings.Theme.allCases, id: \.self) { theme in
Text(theme.rawValue).tag(theme)
}
}
}
}
}struct SettingsView: View {
var settings: SharedState<AppSettings>
var body: some View {
Form {
Picker("Theme", selection: binding(\.theme)) {
ForEach(AppSettings.Theme.allCases, id: \.self) { theme in
Text(theme.rawValue).tag(theme)
}
}
Toggle("Notifications", isOn: binding(\.notificationsEnabled))
Stepper("Daily Goal: \(settings.value.dailyGoal)", value: binding(\.dailyGoal))
}
}
private func binding<T>(_ keyPath: WritableKeyPath<AppSettings, T>) -> Binding<T> {
Binding(
get: { settings.value[keyPath: keyPath] },
set: { newValue in
var updated = settings.value
updated[keyPath: keyPath] = newValue
try? settings.update(updated)
}
)
}
}ApplicationContext has size limits. Keep your state lean:
// Good: Small, focused state
struct UserPreferences: Codable, Sendable, Equatable {
var theme: String
var units: String
}
// Bad: Large state with binary data
struct AppState: Codable, Sendable, Equatable {
var preferences: UserPreferences
var cachedImages: [Data] // Don't include large data!
var fullUserProfile: Profile
}@MainActor
class AppCoordinator {
// Separate concerns into different states
let userPreferences: SharedState<UserPreferences>
let workoutSession: SharedState<WorkoutSession>
let syncStatus: SharedState<SyncStatus>
init(connection: WatchConnection) {
userPreferences = SharedState(initialValue: UserPreferences(), connection: connection)
workoutSession = SharedState(initialValue: WorkoutSession(), connection: connection)
syncStatus = SharedState(initialValue: SyncStatus(), connection: connection)
}
}do {
try settings.update(newSettings)
} catch WatchConnectionError.encodingFailed {
// State couldn't be serialized
showError("Failed to save settings")
} catch {
// Other sync errors
// Note: State is updated locally even if sync fails
// It will be synced when connectivity is restored
}Due to WCSession limitations, calling updateApplicationContext can interfere with sendMessage. If you need to send a message immediately after updating shared state, consider:
// Option 1: Update state, wait briefly, then send message
try settings.update(newSettings)
try await Task.sleep(for: .seconds(1))
let response = try await connection.send(request)
// Option 2: Use request/response instead of shared state for critical data
let response = try await connection.send(UpdateSettingsRequest(settings: newSettings))- Size limits: ApplicationContext has size limits (~250KB). Keep state small.
- Last-write-wins: If both devices update simultaneously, one update will be overwritten. There is no conflict resolution.
- Single value: ApplicationContext stores only the latest value (no history).
- No partial updates: The entire state is replaced on each update.
For large or complex data, consider using request/response patterns instead.