Atoms: Atomic State Management for SwiftUI

repository·main·Indexed 43 days ago

https://github.com/ra1028/swiftui-atom-properties

An 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.

Tokens
11.6K
Snippets
39
Records
46
Agent score
6%

What's inside Atoms

  1. Overview of Atoms state management

    main
    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.
  2. What is an Atom and how does it work?

    main

    An 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 to Hashable, the key is automatically defined. Otherwise, you must define the key property explicitly.
    struct UserNameAtom: StateAtom {
        let userID: Int
    
        var key: Int {
            userID
        }
    
        func defaultValue(context: Context) -> String {
            "Robert"
        }
    }
  3. Isolate atom state with Scoped and AtomScope

    main

    The Scoped attribute 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 an AtomScope. This allows multiple instances of a component (like a SearchPane) 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()
        }
    }
  4. Preserve atom data with KeepAlive

    main

    The KeepAlive attribute allows an atom to preserve its data even if it is no longer being watched by any view. When combined with Scoped, the atom's data is preserved until its parent AtomScope is 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()
        }
    }
  5. Override atoms for dependency injection

    main

    You 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 the AtomRoot.
    • AtomScope.override(...): Overrides the atom value only for the views within that specific AtomScope. 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
    }
  6. Interact with atoms using AtomContext

    main

    An AtomContext provides the primary API for interacting with atom values. Depending on the environment (like AtomViewContext), 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.
  7. Scope atoms using AtomScope and Scoped attribute

    main

    While the library defaults to a single source of truth, you can isolate atom state using AtomScope and the Scoped attribute.

    Scoped Atoms

    An atom marked with the Scoped attribute will preserve its state within the nearest AtomScope ancestor. This prevents the state from being shared with atoms outside that scope.

    Using AtomScope

    • Isolation: Creating multiple AtomScope blocks ensures that Scoped atoms inside them do not share state with each other.
    • Observation: Use .scopedObserve { snapshot in ... } on an AtomScope to monitor changes to atoms within that specific scope.
    • Custom Scopes: You can define a scopeID on a Scoped atom 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()
    }
  8. Apply Atom Modifiers for optimization and tracking

    main

    Modifiers 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 is Equatable compliant. 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)
        }
    }
  9. Display asynchronous data with Suspense

    main

    The Suspense view is used to handle the lifecycle of a Task returned by a watched atom (e.g., a ThrowingTaskAtom). It automatically manages the UI states for loading and error handling.

    • Main Content: The closure that executes once the task completes successfully.
    • suspending closure: An optional view to display while the task is in progress (e.g., a ProgressView).
    • catch closure: An optional view to display if the task fails, providing access to the error.
    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)
            }
        }
    }
  10. Manage side effects with AtomEffect

    main

    The AtomEffect API allows you to synchronize side effects (like logging, persistence, or timers) with an atom's lifecycle. Effects are attached to atoms using the effect(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 AtomEffect protocol 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
        }
    }
  11. Initialize the application with AtomRoot

    main

    To enable the use of atoms in your SwiftUI application, you must wrap your view hierarchy in an AtomRoot view. AtomRoot acts 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 the WindowGroup of your @main App struct).

    @main
    struct ExampleApp: App {
        var body: some Scene {
            WindowGroup {
                AtomRoot {
                    ExampleView()
                }
            }
        }
    }
  12. Debug the dependency graph with Snapshots

    main

    The library manages state using an internal Directed Acyclic Graph (DAG). You can inspect this graph using two methods:

    1. On-demand via @ViewContext: Use context.snapshot() to capture the current state and snapshot.graphDescription() to get a string representation.
    2. Continuous observation: Use the .observe { ... } modifier on AtomRoot (for app-wide changes) or .scopedObserve { ... } on AtomScope (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())
    }