# How to Manage State in FluidVoice: A Complete Guide to SwiftUI Architecture

> Master FluidVoice state management with singletons like SettingsStore AppServices and MenuBarManager Discover efficient SwiftUI architecture for your app

- Repository: [ALTIC/FluidVoice](https://github.com/altic-dev/FluidVoice)
- Tags: how-to-guide
- Published: 2026-07-08

---

**FluidVoice centralizes state management through three SwiftUI-compatible `ObservableObject` singletons: `SettingsStore` for persistent preferences, `AppServices` for lazy-loaded heavy services, and `MenuBarManager` for transient UI state.**

The `altic-dev/FluidVoice` repository demonstrates a production-ready approach to SwiftUI state management that separates concerns between persistence, services, and UI. By leveraging the singleton pattern combined with `ObservableObject`, the application maintains reactive updates across the view hierarchy while avoiding initialization crashes and ensuring data survives app restarts. Understanding how to manage state in FluidVoice provides a blueprint for building robust macOS menu bar applications with complex audio and speech recognition requirements.

## The Three Core State Components

FluidVoice’s architecture divides responsibility across three distinct state containers, each optimized for specific lifecycle and persistence requirements.

### SettingsStore: Persistent User Preferences

**`SettingsStore`** handles durable user settings including prompt profiles, hotkeys, and the launch-at-startup flag. Implemented as an `@MainActor` `ObservableObject` singleton in [`Sources/Fluid/Persistence/SettingsStore.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Persistence/SettingsStore.swift), it persists data to `UserDefaults` and the Keychain while exposing changes through `objectWillChange`.

When any view modifies a published property, the setter automatically synchronizes to `UserDefaults` and notifies subscribers. For example, `launchAtStartupEnabled` writes to `Keys.launchAtStartupEnabled` in `UserDefaults` before calling `objectWillChange.send()`.

### AppServices: Heavy-Weight Runtime Services

**`AppServices`** manages expensive resources like the `AudioHardwareObserver` and `ASRService` (automatic speech recognition). Defined in [`Sources/Fluid/Services/AppServices.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/AppServices.swift), this singleton defers initialization of heavy objects until the UI signals readiness.

The class stores services as private lazy properties (`_audioObserver`, `_asr`) and exposes them through computed properties that check `isUIReady`. This prevents Swift runtime metadata resolution issues that could crash the app during launch.

### MenuBarManager: Transient UI State

**`MenuBarManager`** tracks volatile UI state including recording status, overlay modes, and processing indicators. Located in [`Sources/Fluid/Services/MenuBarManager.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/MenuBarManager.swift), this `@MainActor` `ObservableObject` coordinates between the menu bar and the floating notch overlay.

Its `@Published` properties—including `isRecording`, `isProcessingActive`, and `currentOverlayMode`—drive the visual state of `NotchOverlayManager`, ensuring the UI responds immediately to user actions like hotkey triggers.

## Implementing the Singleton Pattern with ObservableObject

Both `SettingsStore` and `AppServices` expose shared instances via `static let shared`, allowing any view or service to access state without dependency injection complexity.

In [`Sources/Fluid/fluidApp.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/fluidApp.swift), the application entry point initializes these singletons and injects them into the view hierarchy:

```swift
@StateObject private var menuBarManager = MenuBarManager()
@StateObject private var appServices: AppServices
@ObservedObject private var settings = SettingsStore.shared

```

The root view passes these objects downstream using `.environmentObject(...)`, enabling child views to subscribe to changes through `@EnvironmentObject` or `@ObservedObject` property wrappers.

## Persisting State with UserDefaults

`SettingsStore` implements concrete key-based persistence using `UserDefaults`. The store defines type-safe keys (e.g., `Keys.dictationPromptProfiles`, `Keys.hotKeyBindings`) and wraps them in computed properties that handle serialization.

When a view updates a setting, the change propagates through the publisher automatically:

```swift
Toggle("Launch at login", isOn: $settings.launchAtStartupEnabled)
    .onChange(of: settings.launchAtStartupEnabled) { newValue in
        // Automatically persisted to UserDefaults via SettingsStore.setter
        print("Launch-at-startup is now \(newValue)")
    }

```

This pattern ensures that preferences like selected dictation profiles survive app restarts while remaining reactive to SwiftUI binding updates.

## Lazy Service Initialization

To prevent launch-time crashes related to audio hardware enumeration, `AppServices` implements a readiness signaling pattern. The UI calls `signalUIReady()` after the initial layout completes, triggering service initialization:

```swift
.onAppear {
    // Signal that the UI has finished its first layout
    AppServices.shared.signalUIReady()
    // Safely initialize heavy services (audio, ASR)
    AppServices.shared.initializeServicesIfNeeded()
}

```

The `initializeServicesIfNeeded()` method checks the `isUIReady` flag before instantiating `AudioHardwareObserver` or `ASRService`, ensuring metadata resolution occurs after the SwiftUI environment is fully established.

## Managing Transient UI State

`MenuBarManager` bridges user actions and visual feedback through its published properties. When a user triggers a recording hotkey, the manager sets `isRecording = true`, which `NotchOverlayManager` observes to display the floating notch interface.

Views can modify overlay modes directly through bindings:

```swift
Picker("Overlay mode", selection: $menuBar.currentOverlayMode) {
    Text("Dictation").tag(OverlayMode.dictation)
    Text("Command").tag(OverlayMode.command)
}
.pickerStyle(SegmentedPickerStyle())

```

The `@Published` property wrapper ensures that any change to `currentOverlayMode` immediately updates all observing views, including the overlay window and menu bar status item.

## Code Examples

### Reading and Updating Settings

Access persistent preferences through the shared `SettingsStore` instance:

```swift
import SwiftUI

struct PromptSettingsView: View {
    @ObservedObject private var settings = SettingsStore.shared

    var body: some View {
        Toggle("Launch at login", isOn: $settings.launchAtStartupEnabled)
            .onChange(of: settings.launchAtStartupEnabled) { newValue in
                // Persisted automatically via SettingsStore.setter
                print("Launch-at-startup is now \(newValue)")
            }
    }
}

```

### Observing State Changes in Views

Use `@EnvironmentObject` to access `MenuBarManager` from deep within the view hierarchy:

```swift
struct OverlayModeSwitcher: View {
    @EnvironmentObject var menuBar: MenuBarManager

    var body: some View {
        Picker("Overlay mode", selection: $menuBar.currentOverlayMode) {
            Text("Dictation").tag(OverlayMode.dictation)
            Text("Command").tag(OverlayMode.command)
        }
        .pickerStyle(SegmentedPickerStyle())
    }
}

```

### Initializing Heavy Services

Trigger service initialization after the root view appears:

```swift
// In ContentView.onAppear
.onAppear {
    // Signal that the UI has finished its first layout
    AppServices.shared.signalUIReady()
    // Now start heavy services (audio, ASR) safely
    AppServices.shared.initializeServicesIfNeeded()
}

```

### Accessing Prompt Profiles

Retrieve the active dictation prompt or fall back to defaults:

```swift
func currentDictationPrompt() -> String {
    let store = SettingsStore.shared
    let profile = store.selectedDictationPromptProfile
    return profile?.prompt ?? SettingsStore.defaultDictationPromptBodyText()
}

```

## Key Files and Architecture

Understanding these source files clarifies how FluidVoice implements its state management strategy:

- **[`Sources/Fluid/fluidApp.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/fluidApp.swift)**: Application entry point that injects state objects into the SwiftUI environment.
- **[`Sources/Fluid/Persistence/SettingsStore.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Persistence/SettingsStore.swift)**: Centralized, persisted user preferences implementing `ObservableObject`.
- **[`Sources/Fluid/Services/AppServices.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/AppServices.swift)**: Lazy initialization container for audio and speech recognition services.
- **[`Sources/Fluid/Services/MenuBarManager.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/MenuBarManager.swift)**: Transient UI state manager for recording flags and overlay modes.
- **[`Sources/Fluid/ContentView.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/ContentView.swift)**: Root UI that consumes injected objects and coordinates the interface.

## Summary

- **FluidVoice uses three specialized singletons** (`SettingsStore`, `AppServices`, `MenuBarManager`) to separate persistent data, heavy services, and UI state.
- **SettingsStore persists to UserDefaults** while exposing changes via `ObservableObject`, ensuring preferences survive app restarts.
- **AppServices defers initialization** until `signalUIReady()` is called, preventing launch crashes from premature audio hardware access.
- **MenuBarManager coordinates transient state** through `@Published` properties that drive the overlay and menu bar visual feedback.
- **The singleton pattern simplifies access** across the view hierarchy while maintaining SwiftUI reactivity through `objectWillChange` publishers.

## Frequently Asked Questions

### How does FluidVoice save user preferences between app launches?

**`SettingsStore` writes to `UserDefaults` and the Keychain whenever a property changes.** The class implements custom setters that call `objectWillChange.send()` to notify SwiftUI views, then persists the value to `UserDefaults` using type-safe keys like `Keys.launchAtStartupEnabled`. This ensures that settings such as prompt profiles and hotkey bindings are available immediately when the app restarts.

### Why does FluidVoice use singletons instead of dependency injection?

**Singletons provide global access to state without complex injection hierarchies in a menu bar app.** While `MenuBarManager` is instantiated fresh in [`fluidApp.swift`](https://github.com/altic-dev/FluidVoice/blob/main/fluidApp.swift), `SettingsStore` and `AppServices` use `static let shared` to ensure exactly one instance manages persistent data and expensive audio resources. This pattern prevents duplicate service initialization while still allowing SwiftUI views to subscribe to changes through `@ObservedObject` or `@EnvironmentObject`.

### When should I call `signalUIReady()` in FluidVoice?

**Call `signalUIReady()` after the root view completes its initial layout, typically in `onAppear`.** In [`ContentView.swift`](https://github.com/altic-dev/FluidVoice/blob/main/ContentView.swift), this method signals `AppServices` that it is safe to initialize `AudioHardwareObserver` and `ASRService`. Calling it too early can trigger Swift runtime crashes during metadata resolution, while calling it ensures heavy initialization occurs after the UI is responsive.

### How do views access the recording state to update the overlay?

**Views access recording state through `MenuBarManager` published properties.** The `isRecording` and `isProcessingActive` properties are marked with `@Published`, causing SwiftUI to automatically refresh any view using `@EnvironmentObject` or `@ObservedObject`. `NotchOverlayManager` observes these properties to show or hide the floating notch interface when dictation starts or stops.