```html SwiftUI: How to Build Feature Flags (iOS 17+, 2026)
Soarias ← SwiftUI Guides
Architecture

How to Build Feature Flags in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Environment, UserDefaults Updated: May 11, 2026
TL;DR

Create an @Observable FeatureFlags class backed by UserDefaults, inject it via .environment() at the app root, then read flags anywhere with @Environment. Flags persist across launches and can be toggled at runtime without a new build.

@Observable
final class FeatureFlags {
    var newCheckoutFlow: Bool {
        get { UserDefaults.standard.bool(forKey: "flag.newCheckoutFlow") }
        set { UserDefaults.standard.set(newValue, forKey: "flag.newCheckoutFlow") }
    }
}

// In App root:
.environment(FeatureFlags())

// In any child view:
@Environment(FeatureFlags.self) var flags
if flags.newCheckoutFlow {
    NewCheckoutView()
} else {
    LegacyCheckoutView()
}

Full implementation

The pattern below defines a single FeatureFlags object that uses computed properties to read and write directly to UserDefaults, so state is always persisted without any extra serialization step. Because the class is marked @Observable, SwiftUI automatically re-renders any view that reads a flag the moment it changes. A built-in debug settings sheet lets you flip flags interactively during development — it's hidden in production via a compile-time check.

import SwiftUI

// MARK: - Feature Flag Keys
private extension String {
    static let newCheckoutFlow   = "flag.newCheckoutFlow"
    static let improvedOnboarding = "flag.improvedOnboarding"
    static let betaAnalytics     = "flag.betaAnalytics"
}

// MARK: - FeatureFlags Observable Model
@Observable
final class FeatureFlags {
    private let defaults: UserDefaults

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

    var newCheckoutFlow: Bool {
        get { defaults.bool(forKey: .newCheckoutFlow) }
        set { defaults.set(newValue, forKey: .newCheckoutFlow) }
    }

    var improvedOnboarding: Bool {
        get { defaults.bool(forKey: .improvedOnboarding) }
        set { defaults.set(newValue, forKey: .improvedOnboarding) }
    }

    var betaAnalytics: Bool {
        get { defaults.bool(forKey: .betaAnalytics) }
        set { defaults.set(newValue, forKey: .betaAnalytics) }
    }
}

// MARK: - App Entry Point
@main
struct FeatureFlagDemoApp: App {
    private let flags = FeatureFlags()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(flags)
        }
    }
}

// MARK: - Content View
struct ContentView: View {
    @Environment(FeatureFlags.self) private var flags
    @State private var showDebugSheet = false

    var body: some View {
        NavigationStack {
            List {
                Section("Experience") {
                    if flags.improvedOnboarding {
                        Label("New Onboarding Active", systemImage: "sparkles")
                            .foregroundStyle(.purple)
                    } else {
                        Label("Legacy Onboarding Active", systemImage: "person")
                            .foregroundStyle(.secondary)
                    }

                    if flags.newCheckoutFlow {
                        Label("New Checkout Flow Active", systemImage: "cart.badge.plus")
                            .foregroundStyle(.green)
                    } else {
                        Label("Legacy Checkout Flow Active", systemImage: "cart")
                            .foregroundStyle(.secondary)
                    }
                }

                Section("Analytics") {
                    Label(
                        flags.betaAnalytics ? "Beta Analytics ON" : "Beta Analytics OFF",
                        systemImage: "chart.bar"
                    )
                    .foregroundStyle(flags.betaAnalytics ? .blue : .secondary)
                }
            }
            .navigationTitle("Feature Flags Demo")
            .toolbar {
                #if DEBUG
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Debug") { showDebugSheet = true }
                }
                #endif
            }
            .sheet(isPresented: $showDebugSheet) {
                DebugFlagSheet()
                    .environment(flags)
            }
        }
    }
}

// MARK: - Debug Sheet (DEBUG builds only)
#if DEBUG
struct DebugFlagSheet: View {
    @Environment(FeatureFlags.self) private var flags
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        @Bindable var flags = flags
        NavigationStack {
            Form {
                Section("Feature Flags") {
                    Toggle("New Checkout Flow", isOn: $flags.newCheckoutFlow)
                    Toggle("Improved Onboarding", isOn: $flags.improvedOnboarding)
                    Toggle("Beta Analytics", isOn: $flags.betaAnalytics)
                }
                Section {
                    Button("Reset All Flags", role: .destructive) {
                        flags.newCheckoutFlow = false
                        flags.improvedOnboarding = false
                        flags.betaAnalytics = false
                    }
                }
            }
            .navigationTitle("Debug: Feature Flags")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Done") { dismiss() }
                }
            }
        }
        .presentationDetents([.medium])
    }
}
#endif

// MARK: - Preview
#Preview("Flags — all ON") {
    let flags = FeatureFlags(defaults: UserDefaults(suiteName: "preview")!)
    flags.newCheckoutFlow = true
    flags.improvedOnboarding = true
    flags.betaAnalytics = true
    return ContentView()
        .environment(flags)
}

#Preview("Flags — all OFF") {
    ContentView()
        .environment(FeatureFlags(defaults: UserDefaults(suiteName: "preview-off")!))
}

How it works

  1. @Observable + computed properties — Marking FeatureFlags with @Observable means SwiftUI tracks which flags a view reads. When a flag's setter calls defaults.set(_:forKey:), the observation system triggers a re-render only in views that actually read that flag — no unnecessary refreshes.
  2. UserDefaults persistence — Each computed property reads and writes directly to UserDefaults. This means flags survive app restarts automatically. The injected defaults parameter lets tests pass a separate UserDefaults(suiteName:) suite so test state never bleeds into the real app.
  3. Single injection point — Calling .environment(flags) once on the root ContentView makes the object available to every descendant via @Environment(FeatureFlags.self). There's no prop-drilling and no singletons.
  4. @Bindable in the debug sheet — The debug sheet needs two-way bindings to drive Toggle views. The @Bindable var flags = flags line (new in iOS 17) extracts bindable wrappers from the @Observable object, enabling $flags.newCheckoutFlow syntax.
  5. #if DEBUG guard — The debug sheet and its toolbar button are wrapped in #if DEBUG so the flag-flip UI is stripped out of App Store builds at compile time. No runtime checks needed; no accidental flag exposure to production users.

Variants

Remote flags via JSON (over-the-air updates)

For flags you want to flip without a new TestFlight build, fetch a JSON config on launch and merge it into your FeatureFlags. Local UserDefaults serve as the fallback when the network is unavailable.

// Remote config shape: { "flag.newCheckoutFlow": true, ... }
extension FeatureFlags {
    func syncRemote(from url: URL) async throws {
        let (data, _) = try await URLSession.shared.data(from: url)
        let remote = try JSONDecoder().decode([String: Bool].self, from: data)
        for (key, value) in remote {
            defaults.set(value, forKey: key)
        }
    }
}

// In App root:
.task {
    try? await flags.syncRemote(
        from: URL(string: "https://example.com/flags.json")!
    )
}

Per-user flags with App Groups

If your app has a widget or app extension that also needs to read flags, swap UserDefaults.standard for a shared suite: UserDefaults(suiteName: "group.com.yourapp.flags"). Add the App Groups entitlement to both targets and both can read and write the same flag store without any IPC overhead.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement feature flags in SwiftUI for iOS 17+.
Use Environment and UserDefaults.
Make it accessible (VoiceOver labels).
Add a #Preview with realistic sample data.
Include a #if DEBUG settings sheet for toggling flags.
Support injecting a custom UserDefaults suite for tests.

In the Soarias Build phase, paste this prompt directly into the implementation step to scaffold the full flag system in one shot — then use the debug sheet to validate flag behavior before wiring up your real feature screens.

Related guides

FAQ

Does this work on iOS 16?

The @Observable macro and the new @Environment(Type.self) syntax require iOS 17. On iOS 16 you would use ObservableObject with @Published properties and inject via .environmentObject(), but Apple considers that pattern deprecated. If you must support iOS 16, write a thin compatibility shim and plan to remove it when you drop iOS 16.

How do I gate an entire navigation destination, not just UI elements?

Read flags in the NavigationStack path logic. If a route is accessed while the flag is off, redirect to a fallback destination using .navigationDestination(for:). You can also guard deep links in your .onOpenURL handler by checking the flag before pushing to the path array.

What's the UIKit equivalent?

In UIKit the same FeatureFlags class works identically — it's plain Swift. Inject it via a custom init or a dependency container (e.g., pass it through AppDelegate down to coordinators). There's no environment system in UIKit, so you'll typically store the shared instance in a coordinator or pass it explicitly to each view controller that needs it.

Last reviewed: 2026-05-11 by the Soarias team.

```