1 minute read

Small data storage with UserDefaults in iOS

Many iOS apps need to remember a few choices between launches: kilometres instead of miles, a selected appearance, or the last tab someone opened. UserDefaults provides a lightweight key-value store for this kind of small state. It persists outside the running process, is simple to use from UIKit, and suits preferences rather than substantial application data.

The API is useful for an Australian app that remembers metric measurements, a preferred city, or a simple delivery choice. It is not a substitute for a database, a secure vault, or cloud synchronisation. The important decisions are which values belong in preferences, how they are typed, and how privacy expectations are respected.

What UserDefaults is built for

UserDefaults.standard represents the app's main preferences domain. Values are stored as property-list data, including strings, numbers, booleans, dates, arrays, dictionaries, and Data. The system manages the underlying plist inside the app's sandbox, so an app can read a value quickly without opening its own database.

Good candidates are settings a person expects to survive a relaunch: a Celsius or Fahrenheit toggle, a selected map layer, a sound preference, or an onboarding flag. Australian users commonly work with kilometres, Celsius, and a 12-hour clock, although a 24-hour option may be useful for people working with transport or international teams. Australian English labels can also be stored, but stable internal values such as celsius are safer than display text.

Keep the set deliberately small. It should not hold access tokens, passwords, payment details, large downloaded files, or a complete collection of user-generated records. Use Keychain for secrets and Core Data or SwiftData for richer data. UserDefaults also does not synchronise values across a person's devices, and calling synchronize() is normally unnecessary.

Choosing a stable preference model

Treat keys as part of the app's internal API. Prefix them with a feature name, use names that describe meaning rather than a screen, and avoid reusing a key after its meaning changes. A typed wrapper keeps raw string keys in one place and prevents different screens from interpreting the same value differently.

Plan for a missing value. Accessors such as bool(forKey:) can return false when no value exists, which is not always the intended default. register(defaults:) supplies fallback values without overwriting a value already stored by the person. Those registered values remain volatile, so they work as application defaults rather than as a way to create permanent records.

Privacy deserves an explicit decision. For an Australian organisation covered by the Privacy Act 1988, the Australian Privacy Principles can apply to personal information handled through an app, even when the information remains on the device. A precise location or a postcode linked to an account and delivery history can become personal information. Store only what the preference genuinely needs.

Implementing a typed settings layer

A small wrapper provides clearer call sites and makes the storage boundary easy to test:

final class AppPreferences {
    enum Key: String {
        case distanceUnit = "settings.distanceUnit"
        case preferredCity = "settings.preferredCity"
    }

    private let store: UserDefaults

    init(store: UserDefaults = .standard) {
        self.store = store
    }

    var distanceUnit: String {
        get { store.string(forKey: Key.distanceUnit.rawValue) ?? "kilometres" }
        set { store.set(newValue, forKey: Key.distanceUnit.rawValue) }
    }
}

Injecting UserDefaults into the wrapper allows tests to use a separate suite. If several values belong together, a Codable settings structure can be encoded as Data and stored under one key. This is convenient for a fixed group such as map preferences, but the encoded payload should still remain modest.

Design key changes deliberately. If a value changes shape, add a new key or migrate the old value once, then remove the obsolete key when it is no longer needed. Provide a genuine reset operation when the person expects to clear saved choices. A UI-facing wrapper can be isolated to the main actor, while a background service should follow the concurrency rules of the rest of the app.

Using preferences in UIKit

Read a setting when the relevant screen appears, then apply it to the control or model that needs it. In UIKit, a view controller can obtain the value in viewDidLoad and update a segmented control, switch, label, or table view. For a SwiftUI view, @AppStorage offers a similar automatic bridge. Neither approach is a reason to read a large collection during every cell configuration.

When a setting can change while the interface is open, observe UserDefaults.didChangeNotification. Perform UIKit updates on the main actor, and remove a selector-based observer when its owner is deallocated. A central settings object can publish a regular notification with a strongly typed key if that is easier for the app to follow.

Australian retail and transport apps show why this boundary matters. A customer using Woolworths or Coles may alternate between delivery and store pickup, while services in Sydney, Melbourne, Brisbane, and Perth span several Australian time zones. Remembering a fulfilment mode or preferred city can improve the experience. Keep the full street address out of UserDefaults, and calculate GST-inclusive totals at checkout rather than trusting a preference that could become stale.

Testing and resetting stored values

Tests should not alter the developer's ordinary preferences. Create a uniquely named suite for each test, write to it, and remove its persistent domain afterwards:

let suiteName = "preferences.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer {
    defaults.removePersistentDomain(forName: suiteName)
}

Assert the public behaviour exposed by the wrapper: the default used for a missing key, the value returned after a write, and the result of calling removeObject(forKey:). A separate suite also lets a test verify registration and migration without leaking state into UserDefaults.standard.

This isolation follows the same principle as container-based integration testing: each run receives controlled external state instead of sharing it with unrelated work. The tools differ, but the outcome is the same. A deterministic preferences test makes failures easier to understand and keeps a local test run from changing the developer's own settings.

A dependable preferences checklist

A reliable implementation keeps storage narrow, predictable, and easy to inspect:

  • Store only lightweight, non-sensitive preferences.
  • Namespace keys and access them through one typed layer.
  • Register clear fallback values for missing keys.
  • Keep secrets, large records, and sync state in purpose-built storage.
  • Test with an isolated suite and clean it up after each run.

A reset control can clear the relevant keys or suite domain when a person wants a clean slate. Values normally survive ordinary app updates but are removed when the app is deleted. For preferences that affect an order, account, or access decision, read the current source at the moment it matters rather than relying on an old cached value.

Remember UserDefaults as a convenient preferences cabinet, not a secure vault or general-purpose database: keep it small, typed, private, and easy to reset.

Updated: