Atoms: Atomic State Management for SwiftUI
repository·main·Indexed 43 days ago
https://github.com/ra1028/swiftui-atom-propertiesAn atomic approach to state management and dependency injection for SwiftUI. Atoms provides reactive data binding, compile-safe dependency injection, and effective caching of derived data. It features a variety of atom types including StateAtom for mutable state, ValueAtom for derived data, and specialized atoms for asynchronous operations like TaskAtom, ThrowingTaskAtom, AsyncSequenceAtom, and PublisherAtom. The library includes property wrappers such as @Watch, @WatchState, and @WatchStateObject to bind state to views, and an AtomRoot provider to manage the data store.
What's inside Atoms
- Atoms is an atomic approach to state management and dependency injection for SwiftUI. It allows you to build state by compositing individual atoms, which automatically optimizes rendering based on a dependency graph. This prevents performance degradation caused by unnecessary re-renders. Atoms effectively combines state management and dependency injection to help build robust and testable applications.
What is an Atom and how does it work?
mainAn Atom represents a piece of state and serves as the single source of truth for your application. Atoms can represent either raw state or derived data (by transforming other atoms).
Key Characteristics:
- No Global Data: Atoms do not hold global data themselves; they retrieve values from the store provided by
AtomRoot. - Testability: Because they rely on a provided context/store, they can be accessed from anywhere without losing testability.
- Identification: Atoms are associated with values using a unique
key. If the atom conforms toHashable, the key is automatically defined. Otherwise, you must define thekeyproperty explicitly.
struct UserNameAtom: StateAtom { let userID: Int var key: Int { userID } func defaultValue(context: Context) -> String { "Robert" } }- No Global Data: Atoms do not hold global data themselves; they retrieve values from the store provided by
Isolate atom state with Scoped and AtomScope
mainThe
Scopedattribute preserves atom state in the nearest ancestor scope and prevents it from being shared outside that scope. To implement this, wrap the consuming views in anAtomScope. This allows multiple instances of a component (like aSearchPane) to each have their own isolated state for the same atom type.struct SearchQueryAtom: StateAtom, Scoped, Hashable { func defaultValue(context: Context) -> String { "" } } VStack { AtomScope { SearchPane() } AtomScope { SearchPane() } }Preserve atom data with KeepAlive
mainThe
KeepAliveattribute allows an atom to preserve its data even if it is no longer being watched by any view. When combined withScoped, the atom's data is preserved until its parentAtomScopeis dismantled from the view tree. This is useful for caching data (like master data from a server) in memory for the duration of a scope or the app process.struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable { func value(context: Context) async throws -> MasterData { try await fetchMasterData() } }Override atoms for dependency injection
mainYou can override atom values to inject dependencies or fake state for specific parts of your view hierarchy.
AtomRoot.override(...): Overrides the atom value globally for all descendant views in theAtomRoot.AtomScope.override(...): Overrides the atom value only for the views within that specificAtomScope. Atoms used in nested scopes outside this one will not be affected.
// Global override in AtomRoot AtomRoot { RootView() } .override(CounterAtom()) { _ in 456 } // Scoped override in AtomScope AtomScope { CountDisplay() } .override(CounterAtom()) { _ in 456 }Interact with atoms using AtomContext
mainAn
AtomContextprovides the primary API for interacting with atom values. Depending on the environment (likeAtomViewContext), different APIs are available. Common methods include:watch(_:): Gets an atom value and starts watching its updates.read(_:): Gets an atom value without watching updates.set(_:for:): Sets a new value to the atom.modify(_:body:): Modifies the cached atom value.subscript[]: Provides read-write access for applying mutating methods.refresh(_:): Produces a new value and waits for asynchronous completion.reset(_:): Resets an atom to its default or first output.
AtomViewContext(available via@ViewContext) adds:binding(_:): Gets a binding to the atom state.snapshot(): Captures a snapshot of specific atom values for debugging.
Scope atoms using AtomScope and Scoped attribute
mainWhile the library defaults to a single source of truth, you can isolate atom state using
AtomScopeand theScopedattribute.Scoped Atoms
An atom marked with the
Scopedattribute will preserve its state within the nearestAtomScopeancestor. This prevents the state from being shared with atoms outside that scope.Using AtomScope
- Isolation: Creating multiple
AtomScopeblocks ensures thatScopedatoms inside them do not share state with each other. - Observation: Use
.scopedObserve { snapshot in ... }on anAtomScopeto monitor changes to atoms within that specific scope. - Custom Scopes: You can define a
scopeIDon aScopedatom to find a matching scope in a nested hierarchy, allowing for controlled sharing across specific boundaries.
// Isolated atoms via Scoped attribute struct TextInputAtom: StateAtom, Scoped, Hashable { func defaultValue(context: Context) -> String { "" } } VStack { // These two views will NOT share TextInputAtom state AtomScope { TextInputView() } AtomScope { TextInputView() } } // Sharing via custom scope ID struct TextScopeID: Hashable {} struct TextInputAtom: StateAtom, Scoped, Hashable { var scopeID: TextScopeID { TextScopeID() } func defaultValue(context: Context) -> String { "" } } AtomScope(id: TextScopeID()) { TextInputView() } AtomScope { // Shares state with the TextInputView in the parent scope TextInputView() }- Isolation: Creating multiple
Apply Atom Modifiers for optimization and tracking
mainModifiers can be applied to any atom to create a derived version of that atom. This is useful for performance optimization (reducing view re-computations) or tracking specific state changes.
Available Modifiers:
.previous: Provides the previous value of the atom instead of the current one. Useful for comparing changes..latest(_:): Provides the most recent value that matches a specific condition (using a KeyPath)..changes(of:): Derives a partial property using a KeyPath. The view will only re-compute if the specific property changes and isEquatablecompliant. This is a key performance optimization tool.
// Example: Using .previous and .latest struct CounterView: View { @WatchState(CounterAtom()) var currentValue @Watch(CounterAtom().previous) var previousValue @Watch(ItemAtom().latest(\.isValid)) var latestValidItem var body: some View { VStack { Text("Current: \(currentValue)") Text("Previous: \(previousValue ?? 0)") } } } // Example: Using .changes(of:) for performance struct CountDisplayView: View { @Watch(CountAtom().changes(of: \.description)) var description var body: some View { Text(description) } }Display asynchronous data with Suspense
mainThe
Suspenseview is used to handle the lifecycle of aTaskreturned by a watched atom (e.g., aThrowingTaskAtom). It automatically manages the UI states for loading and error handling.- Main Content: The closure that executes once the task completes successfully.
suspendingclosure: An optional view to display while the task is in progress (e.g., aProgressView).catchclosure: An optional view to display if the task fails, providing access to theerror.
struct NewsView: View { @Watch(LatestNewsAtom()) var newsTask: Task<News, any Error> var body: some View { Suspense(newsTask) { news in Text(news.content) } suspending: { ProgressView() } catch: { error in Text(error.localizedDescription) } } }Manage side effects with AtomEffect
mainThe
AtomEffectAPI allows you to synchronize side effects (like logging, persistence, or timers) with an atom's lifecycle. Effects are attached to atoms using theeffect(context:)method.Predefined Effects
InitializingEffect: Runs before the atom is initialized.InitializeEffect: Runs after the atom is initialized.UpdateEffect: Runs whenever the atom's value is updated.ReleaseEffect: Runs when the atom is no longer used and is released.
Custom Effects
You can implement the
AtomEffectprotocol to manage stateful side effects. The effect is retained as long as the atom is in use.Example of a custom effect managing a timer:
struct CounterAtom: StateAtom, Hashable { func defaultValue(context: Context) -> Int { 0 } func effect(context: CurrentContext) -> some AtomEffect { CountTimerEffect() } } final class CountTimerEffect: AtomEffect { private var timer: Timer? func initialized(context: Context) { timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in context[CounterAtom()] += 1 } } func updated(context: Context) { print("Count: \(context.read(CounterAtom()))") } func released(context: Context) { timer?.invalidate() timer = nil } }Initialize the application with AtomRoot
mainTo enable the use of atoms in your SwiftUI application, you must wrap your view hierarchy in an
AtomRootview.AtomRootacts as the provider for the underlying data store that atoms use to retrieve and manage state. It should be placed at the root of your application (e.g., inside theWindowGroupof your@mainApp struct).@main struct ExampleApp: App { var body: some Scene { WindowGroup { AtomRoot { ExampleView() } } } }Debug the dependency graph with Snapshots
mainThe library manages state using an internal Directed Acyclic Graph (DAG). You can inspect this graph using two methods:
- On-demand via
@ViewContext: Usecontext.snapshot()to capture the current state andsnapshot.graphDescription()to get a string representation. - Continuous observation: Use the
.observe { ... }modifier onAtomRoot(for app-wide changes) or.scopedObserve { ... }onAtomScope(for scoped changes) to receive snapshots whenever state changes.
Captured snapshots can be converted to DOT format for visualization with Graphviz, or used for time travel debugging by calling
context.restore(snapshot)to revert the dependency graph and atom values to a previous state.@ViewContext var context var debugButton: some View { Button("Dump dependency graph") { let snapshot = context.snapshot() print(snapshot.graphDescription()) } } // Or continuous observation AtomRoot { HomeScreen() } .observe { snapshot in print(snapshot.graphDescription()) }- On-demand via