SwiftUI @State Considered Harmful
The Problem with @State for Collections
When you’re building a SwiftUI app with lists of items, like say podcast episodes, the
obvious approach is @State:
struct EpisodeListView: View {
@State private var episodes: [Episode] = []
var body: some View {
List(episodes) { episode in
EpisodeRow(episode: episode)
}
}
}
This seems to work at first. But then I notice UI stuttering while using the app…
The moment you change any property on any episode, SwiftUI
invalidates the entire @State array. Every EpisodeRow
re-renders. With 100 episodes and a playback position updating every second, you’re
re-rendering 100 rows every second. 1,000 episodes and things get real sad. Your UI
stutters. Your battery drains.
The fundamental problem: @State treats the entire array
as a single unit of change. SwiftUI can’t see inside structs to know that only
episodes[42].playbackPosition changed, for example. It sees “the array
changed” and rebuilds everything.
The Solution: Observable Collections of Observables with Identity Preservation
SwiftUI seems to have a lot of the downsides of the “just rerender everything” frameworks like React. Luckily, I have dealt with react, preact, svelte, etc. Granular reactivity requires granular changes and stable identity.
After a lot of iteration, this of what I’ve landed on. I am not a SwiftUI expert, so please tell me if this is terrible.
Ive made each item an @Observable class, stored them by reference
in a dictionary, and I update them in-place.
@MainActor
@Observable
final class Episode: Identifiable {
var id: String
var title: String
var playbackPosition: Int
var isDownloaded: Bool
// ... other properties
func update(from snapshot: EpisodeSnapshot) {
self.title = snapshot.title
self.playbackPosition = snapshot.playbackPosition
self.isDownloaded = snapshot.isDownloaded
// ... update all properties except identity (id)
}
}
When I build an app I am dogmatic that there should be one “app state” that exists outside the UI and is granularly reactive by default. It can be unit tested. And when I look at this object I should be able to imagine what the UI looks like.
@MainActor
@Observable
final class AppState {
var episodes: [String: Episode] = [:]
func mergeEpisodes(_ incoming: [String: EpisodeSnapshot]) {
let incomingIds = Set(incoming.keys)
let existingIds = Set(episodes.keys)
// Remove deleted
for id in existingIds.subtracting(incomingIds) {
episodes.removeValue(forKey: id)
}
// Update existing or add new
for (id, incomingEpisode) in incoming {
if let existing = episodes[id] {
existing.update(from: incomingEpisode) // Update in-place!
} else {
episodes[id] = Episdoe(from: incomingEpisode) // New item
}
}
}
}
This gives me:
-
Granular updates: When
episode.playbackPositionchanges, only views observing that specific property re-render -
Stable object identity: The same
Episodeinstance persists across refreshes, so SwiftUI can diff efficiently -
Single source of truth: One
Episodeinstance per ID, owned byAppState
Questions You’re Probably Asking
1. “Why a class instead of a struct?”
Structs are value types. When you put structs in an array and modify one, you get a new array. SwiftUI sees a new array and re-renders everything.
Classes are reference types. When you modify a property on a class instance, the
reference stays the same. With @Observable, SwiftUI tracks which
properties each view actually reads and only re-renders when those specific properties
change on the class instance.
// With structs: entire list re-renders
episodes[0].playbackPosition = 100 // Creates new array
// With classes: only views reading playbackPosition re-render
episodes["id"]?.playbackPosition = 100 // Same reference, property changes
2. “Won’t this cause memory leaks or retain cycles?”
Not if you follow the ownership model. AppState owns all
Episode instances in a dictionary. Views receive references through
lookups (appState.episode(id: episodeId)). There are no closures capturing
episodes, no parent-child cycles—just a flat owner (AppState) and temporary readers
(views).
Do you know a better way to enforce this? Let me know, hit me up on mastodon.
3. “Why a dictionary instead of an array?”
Two reasons:
-
Fast lookups by ID - Detail views might need to look up episodes frequently. Make it easy.
-
Merging is clearer - When I refresh from disk/network, I get a new set of episodes. With a dictionary, it’s obvious how to merge: update existing keys in-place, add new keys, remove missing keys.
Also, computed properties give you arrays when you need them:
var allEpisodes: [Episode] {
episodes.values.sorted { $0.date > $1.date }
}
Don’t sleep on computed properties: those are observable too.
4. “What about thread safety?”
Both Episode and AppState are marked @MainActor,
so they can only be accessed from the main thread. The compiler enforces this for you.
For crossing actor boundaries (like loading from disk on a background thread), I use an
EpisodeSnapshot struct. Snapshots are plain value types that can be safely
sent anywhere. Background work produces snapshots, then sends them to
AppState on the main actor to be merged in.
// Background actor loads data and creates snapshots
let snapshots: [String: EpisodeSnapshot] = await loadFromDisk()
// Send to main actor for merging
await appState.mergeEpisodes(snapshots)
This keeps the boundary clean: snapshots cross actors, Episodes stay on main.
5. “Why not just use @ObservationIgnored on frequently-changing properties?”
Because then SwiftUI doesn’t see the changes at all. If playbackPosition
is ignored, the progress indicator in the UI never updates. Also, “app state” should
only be the actual state. If it isn’t useful for the app, then it shouldn’t be in
there.
The real solution is structuring views to observe only what they display:
// Good: This view only observes title and date
struct EpisodeRow: View {
let episode: Episode
var body: some View {
VStack {
Text(episode.title)
Text(episode.date, style: .date)
}
}
}
// The playback position view is separate
struct PlaybackProgress: View {
let episode: Episode
var body: some View {
ProgressView(value: Double(episode.playbackPosition),
total: Double(episode.duration))
}
}
Now playback position updates only re-render PlaybackProgress, not
EpisodeRow.
6. “How do views get fresh data after updates?”
Detail views should always look up from AppState:
struct EpisodeDetailView: View {
let episodeId: String // Just the ID
@Environment(AppState.self) private var appState
private var episode: Episode? {
appState.episode(id: episodeId) // Always fresh
}
var body: some View {
if let episode {
Text(episode.title)
// ...
}
}
}
The view holds an ID, not a stale copy. Every access goes through AppState, which holds the authoritative instance.
7. “Does this help with testing?”
Enormously. All state lives in AppState and I bundle all mutations in
AppActions. Tests don’t need to render SwiftUI at all:
@MainActor
func testDownloadUpdatesEpisode() async throws {
let appState = AppState(...)
let appActions = AppActions(appState: appState, ...)
// Seed an episode
let episode = Episode(id: "test", isDownloaded: false, ...)
appState.episodes["test"] = episode
// Perform action
await appActions.downloadEpisode(episode)
// Assert state changed
XCTAssertTrue(appState.episodes["test"]?.isDownloaded == true)
}
No UI, no simulators, fast and deterministic. The views are just projections of state, so I can just test the state.
8. “What about derived/computed state like ‘downloaded episodes’?”
Keep derived state as computed properties on AppState:
var downloadedEpisodes: [Episode] {
episodes.values.filter { $0.isDownloaded }.sorted { $0.date > $1.date }
}
var newEpisodes: [Episode] {
episodes.values.filter { !$0.hasPlayed }.sorted { $0.date > $1.date }
}
These recompute on access, but because @Observable tracks reads, a view
that only accesses downloadedEpisodes won’t re-render when
newEpisodes would change (unless the underlying episodes data changed in a
way that affects both). You can move to a memoized version if you find you need to
optimize, but wait IMO.
9. “How do you handle navigation with reactive data?”
Navigate by ID, not by object:
// Good: Pass ID
NavigationLink(value: NavigationRoute.episode(episode.id)) {
EpisodeRow(episode: episode)
}
// In destination
.navigationDestination(for: NavigationRoute.self) { route in
switch route {
case .episode(let id):
EpisodeDetailView(episodeId: id)
}
}
The detail view looks up fresh data from AppState using the ID. Even if the episode updates while navigating, the detail view sees the current state.
10. “What about high-frequency updates like playback progress?”
Throttle before mutating state. I use Combine to debounce playback callbacks:
// AudioPlayerService sends callbacks frequently
// A Combine pipeline throttles to ~250ms before updating AppState
playerProgressPublisher
.throttle(for: .milliseconds(250), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] time in
self?.appState.currentTime = time
}
The audio callback fires 60x/second. AppState updates 4x/second. The UI is smooth, and you’re not burning CPU on excessive re-renders.
11. “How does this compare to Redux/TCA?”
Similar in spirit, different in mechanics. Like Redux/TCA:
- Single source of truth (AppState)
- Unidirectional flow (Action → State → UI)
- Testable without UI
Unlike Redux/TCA:
- No action types or reducers—just methods on AppActions
- Native Swift observation instead of custom subscription system
- Reference types for items that need granular updates
It’s less ceremony, more direct mutation, but the same core principles.
Any other questions I missed? Hit me up on mastodon.