Introduction
After three decades into your iOS development journey, you've built enough features to know when you need persistence—and when you don't. UserDefaults and @AppStorage in Swift, SwiftUI & iOS Apps represent the lightest touch for storing user preferences, but they're also one of the easiest places to make mistakes. We'll walk through exactly when to reach for them, how @AppStorage binds your SwiftUI views directly to the system defaults, and why you'll want something else for anything sensitive or substantial.
What @AppStorage Does
@AppStorage is a SwiftUI property wrapper that syncs a view's state with UserDefaults automatically. It's the bridge between your view and the system-wide key-value store, eliminating boilerplate and reactive plumbing you'd otherwise write by hand.
Here's a real example from a travel app where we're toggling dark mode preference:
import SwiftUI
struct SettingsView: View {
@AppStorage("isDarkModeEnabled") var isDarkModeEnabled = false
@AppStorage("maxHotelsPerSearch") var maxHotels = 10
var body: some View {
Form {
Toggle("Enable Dark Mode", isOn: $isDarkModeEnabled)
Stepper("Max Hotels: \(maxHotels)", value: $maxHotels, in: 1...50)
}
}
}
That's it. When the user toggles the switch, @AppStorage writes to UserDefaults immediately. When the app relaunches, the values persist. No explicit save, no delegate callbacks, no manual UserDefaults calls.
Under the hood, @AppStorage is wrapping UserDefaults.standard with a Combine publisher. Every write triggers an update; every read respects the stored value or the default you provided. This makes it perfect for settings that don't need validation or complex state management.
UserDefaults: The Foundation
Before @AppStorage, engineers reached for UserDefaults directly. It's still there, still useful, and still the backend that @AppStorage uses. Understanding it helps you know when to drop back to the manual API.
UserDefaults stores strings, numbers, dates, arrays, and dictionaries—not arbitrary objects. It's meant for settings, not app data. Here's how you'd store and retrieve without @AppStorage:
// Writing
UserDefaults.standard.set(true, forKey: "isDarkModeEnabled")
UserDefaults.standard.set(10, forKey: "maxHotels")
// Reading
let isDark = UserDefaults.standard.bool(forKey: "isDarkModeEnabled")
let maxHotels = UserDefaults.standard.integer(forKey: "maxHotels")
// With defaults
let maxHotels = UserDefaults.standard.integer(forKey: "maxHotels") == 0 ? 10 : UserDefaults.standard.integer(forKey: "maxHotels")
Notice the friction: you're checking keys as strings, handling missing values, casting types. @AppStorage hides all of that. But in a UIViewController-based app or when you need to share defaults across teams or app groups, you might still write UserDefaults directly. The key insight is that @AppStorage is just a convenience wrapper—it doesn't reinvent the storage layer.
Only for Small, Non-Sensitive Values
UserDefaults is fast and synchronous, but it's also stored in plaintext on disk. It's readable by any code running in your app's sandbox, and in theory, it could be inspected by a determined attacker with a jailbroken device. For this reason, we never store passwords, API keys, tokens, or PII in UserDefaults or @AppStorage.
The "small" part also matters. UserDefaults is not a database. If you're storing user-generated content—messages, notes, photos—you need SwiftData or Core Data. UserDefaults is for toggles, counts, preferences, and simple flags that control how your app behaves.
Here's a real scenario from an e-commerce app where we store a user preference but not payment info:
struct CheckoutView: View {
@AppStorage("preferredPaymentMethod") var preferredMethod: String = "creditCard"
@State var cardNumber: String = ""
var body: some View {
Form {
Picker("Preferred Payment", selection: $preferredMethod) {
Text("Credit Card").tag("creditCard")
Text("Apple Pay").tag("applePay")
}
// WRONG: never store cardNumber in UserDefaults
SecureField("Card Number", text: $cardNumber)
}
}
}
The preference can live in UserDefaults. The card number must be submitted to a secure backend or stored in Keychain, never in UserDefaults.
Use Keychain for Secrets
When you need to persist something sensitive—an auth token, a refresh token, biometric credentials—reach for Keychain. Keychain encrypts values at rest and protects them from casual inspection.
Apple's approach is to use the Security framework or a wrapper library. Here's a minimal example of storing a token:
import Security
func storeToken(_ token: String, forKey key: String) {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func retrieveToken(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue as Any,
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return result.flatMap { data in
String(data: data as! Data, encoding: .utf8)
}
}
In production, most teams use a third-party library like Keychain wrappers or Swift's built-in Secure Enclave APIs. The principle is the same: separate secret storage from UserDefaults.
Use SwiftData for Real Data
If you're storing structured app data—orders, messages, user profiles, search history—UserDefaults and @AppStorage are wrong tools. SwiftData is the modern replacement for Core Data in Swift 5.9+. It integrates seamlessly with SwiftUI and handles relationships, queries, and migrations.
Here's the difference: imagine a ride-hailing app tracking favorite locations and completed rides.
Wrong approach (UserDefaults):
@AppStorage("favoriteLocations") var favoriteLocations: [String] = []
@AppStorage("rideHistory") var rideHistory: [String] = []
You're hand-serializing complex data into JSON, losing type safety, and making queries impossible.
Right approach (SwiftData):
import SwiftData
@Model
final class FavoriteLocation {
var name: String
var latitude: Double
var longitude: Double
var dateAdded: Date
init(name: String, latitude: Double, longitude: Double) {
self.name = name
self.latitude = latitude
self.longitude = longitude
self.dateAdded = Date()
}
}
@Model
final class CompletedRide {
var pickupLocation: FavoriteLocation?
var dropoffLocation: FavoriteLocation?
var distance: Double
var fare: Double
var rideDate: Date
}
struct RidesView: View {
@Query(sort: \.rideDate, order: .reverse) var rides: [CompletedRide]
@Environment(\.modelContext) var context
var body: some View {
List(rides) { ride in
Text("\(ride.pickupLocation?.name ?? "Unknown") → \(ride.dropoffLocation?.name ?? "Unknown")")
}
}
}
SwiftData handles persistence, relationships, and querying. @Query automatically updates the view when data changes. This is the right tool when your data has structure.
Practical Patterns in Production
We've found a few patterns that work well across our SaaS and mobile teams:
Environment-based defaults: Different feature flags or API endpoints per environment (dev, staging, prod). Store these in @AppStorage during onboarding or build time.
@AppStorage("apiBaseURL") var apiBaseURL: String = "https://api.production.example.com"
@AppStorage("enableDebugLogging") var enableDebugLogging: Bool = false
Cached user preferences: Once you fetch the user's settings from your backend, cache the lightweight pieces in @AppStorage so the UI stays snappy while you refresh in the background.
@AppStorage("cachedTheme") var cachedTheme: String = "light"
func syncUserSettings() async {
let settings = try await api.fetchUserSettings()
DispatchQueue.main.async {
self.cachedTheme = settings.theme
}
}
Onboarding state: Track whether the user has completed onboarding, accepted terms, or configured their profile.
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
@AppStorage("acceptedTermsVersion") var acceptedTermsVersion: Int = 0
Common Mistakes
Storing encrypted data in UserDefaults. It's not encrypted. If you encrypt something yourself and store the result, you've added overhead without real security. Use Keychain instead.
Using @AppStorage for large arrays or dictionaries. UserDefaults serializes and deserializes the entire value on every access. A 10 MB array will slow your app noticeably. Use SwiftData.
Assuming @AppStorage updates are atomic. If your app crashes between writing multiple keys, you might end up with inconsistent state. Use transactions in SwiftData if atomicity matters.
Not namespacing keys. If you use generic keys like "isEnabled" across multiple modules, you'll have collisions. Prefix them: "ChatModule.isEnabled".
Forgetting to handle missing keys gracefully. Always provide defaults. If you read a key that doesn't exist, UserDefaults returns a sensible zero value (0, false, empty string), but it's clearer to be explicit with @AppStorage's initializer.
Key Takeaways
- @AppStorage is a SwiftUI property wrapper that binds views to UserDefaults, eliminating boilerplate for simple preferences.
- Use @AppStorage and UserDefaults only for small, non-sensitive values like feature flags, UI preferences, and user toggles.
- Never store passwords, tokens, or PII in UserDefaults. Use Keychain for secrets.
- Use SwiftData for structured app data like orders, messages, or search history that has relationships or requires querying.
- Always provide sensible defaults when reading values, and prefix keys to avoid collisions across your app.






Comments
Loading comments…