How to Build Feature Flags in SwiftUI
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
-
@Observable + computed properties — Marking
FeatureFlagswith@Observablemeans SwiftUI tracks which flags a view reads. When a flag's setter callsdefaults.set(_:forKey:), the observation system triggers a re-render only in views that actually read that flag — no unnecessary refreshes. -
UserDefaults persistence — Each computed property reads and writes directly to
UserDefaults. This means flags survive app restarts automatically. The injecteddefaultsparameter lets tests pass a separateUserDefaults(suiteName:)suite so test state never bleeds into the real app. -
Single injection point — Calling
.environment(flags)once on the rootContentViewmakes the object available to every descendant via@Environment(FeatureFlags.self). There's no prop-drilling and no singletons. -
@Bindable in the debug sheet — The debug sheet needs two-way bindings to drive
Toggleviews. The@Bindable var flags = flagsline (new in iOS 17) extracts bindable wrappers from the@Observableobject, enabling$flags.newCheckoutFlowsyntax. -
#if DEBUG guard — The debug sheet and its toolbar button are wrapped in
#if DEBUGso 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
- iOS 17 @Observable requirement. The
@Bindableextraction syntax and the no-wrapper@Environment(FeatureFlags.self)form both require iOS 17+. On iOS 16 you'd needObservableObject+@Published, but those are deprecated going forward — don't backport unless you still need iOS 16 support. - Forgetting to inject before reading. If a child view reads
@Environment(FeatureFlags.self)but no ancestor called.environment(flags), you'll get a fatal crash at runtime: "No FeatureFlags found in environment." Always inject at the app root, and inject again in previews. - Observation not firing for UserDefaults changes. Because the computed properties read from
UserDefaultsrather than stored properties, the@Observablemacro can't auto-track them. You must callwithMutation(keyPath:)or trigger observation manually if you set flags from outside the class. The implementation above avoids this by always going through the class setters. - Accessibility. Conditional UI controlled by flags can confuse VoiceOver if a view appears mid-session. Use
.accessibilityLabel()on gated views and consider posting an accessibility notification (UIAccessibility.post(notification: .screenChanged, argument: nil)) when a major layout change fires due to a flag toggle.
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.