Skip to content
Development

The setting that worked perfectly and felt completely broken

By Victor Da Luz
iosswiftswiftuiuxdev-logdeep-cut-atlas

I added a Settings screen to Deep Cut Atlas this week. One section lets you set the default filter for the Discover tab - show me only albums, hide singles, that sort of thing. I built it, the unit tests passed, the toggles flipped, UserDefaults saved. Done.

Then I walked through how I’d actually demo it: “change the default in Settings, go back to Discover, and you’ll see it applied.” Except you wouldn’t. Tracing the actual sequence of taps, I realized this was going to feel like a bug.

It took me a second to see why, because the code was correct. But “correct” and “not broken” turned out to be different things.

Why nothing happened

Here’s how each tab sets itself up:

struct DiscoverView: View {
    @State private var viewModel: DiscoverViewModel?
    var body: some View { /* ... */ }
        .task {
            if viewModel == nil {
                viewModel = DiscoverViewModel(defaultTypes: settings.filterTypes)
                await viewModel?.load()
            }
        }
}

The view model is created once - note the if viewModel == nil - seeding its filter from the saved default at that moment. That’s a normal pattern.

The catch is the word “once.” In a TabView, the tabs don’t get torn down and rebuilt when you switch between them. SwiftUI keeps them all alive so switching is instant. So that .task runs a single time for the entire life of the app, and the filter gets seeded exactly once - at first launch.

Which means: open Settings, change the Discover default, go back to Discover. The view model was built ages ago. It never re-reads the setting. Nothing changes. The only thing that would apply your new default is fully quitting and relaunching the app.

So the demo is: change a setting, watch it do nothing, and the only way to make it “work” is to force-quit. That’s not a bug in the sense of a crash or wrong output - the code does exactly what it says. But it’s absolutely a bug in the sense that matters, which is the person using it.

The fix was to delete the idea, not patch it

My first instinct was to make the tab re-read the setting - on appear, on sheet dismiss, somewhere. But every version of that fought itself. Re-seed when the tab reappears? Then switching tabs wipes out the filter you set in the tab. Re-seed only when it changed? Now I’m tracking change tokens. Each patch added a special case, which is usually the smell that the model is wrong.

The model was wrong. I had two concepts - a “default” stored in Settings and a “current filter” living in the tab - and I was forever trying to sync them. So I deleted one of them. There is now just the filter, and it lives in one shared, persisted store:

@MainActor @Observable
final class DiscoverViewModel {
    private let settings: SettingsStore   // shared, injected
    var selectedTypes: Set<RecordingType> {
        get { settings.filterTypes }
        set { settings.filterTypes = newValue }
    }
}

The tab’s filter chips and the Settings screen now bind to the same thing. Change it anywhere, it changes everywhere, live, and it’s remembered across launches. There’s no “default versus current” because there’s no longer two of anything. The whole class of staleness bugs just evaporated, because there was nothing left to keep in sync.

It also turned out to be the behavior people actually expect. “Remember the filter I picked” is what almost every app does. The “reset to a default each session” model I’d accidentally built isn’t even desirable - it was just an artifact of seeding state at construction.

The part I want to remember

Two things stuck with me. First, the SwiftUI-specific one: a view model held in a tab’s @State outlives every tab switch, so anything you seed into it at creation is frozen until the app relaunches. If a value needs to track a source that can change, don’t copy it in - read from the shared source.

Second, the bigger one: passing tests and a clean diff told me nothing about whether the feature was good. It took imagining the actual sequence of taps - change setting, see nothing, force-quit - to surface a problem the code could never reveal on its own. The most valuable review of this feature wasn’t about the code at all. It was picturing a person using it, and noticing that the person would feel lied to.

Related reading