Core Concepts

August 8, 2026 · View on GitHub

Understanding the fundamental building blocks of Swift State Graph.

Overview

Swift State Graph is built around a simple but powerful concept: a graph where nodes represent values and edges represent dependencies. When a value changes, the graph automatically propagates those changes to all dependent values.

The framework provides two primary types of nodes that form the foundation of your reactive data models.

Stored Value Nodes

Stored nodes are containers for values that can be set directly from the outside. They serve as the foundation of your state graph - the "source of truth" from which all other values are derived.

Basic Usage

import StateGraph

// Creating a stored node directly
let counter = Stored(wrappedValue: 0)

// Reading the value
let currentCount = counter.wrappedValue // 0

// Updating the value
counter.wrappedValue = 1

Using the @GraphStored Macro

For cleaner syntax in your classes, use the @GraphStored macro:

final class UserProfile {
  @GraphStored
  var name: String = ""
  
  @GraphStored
  var email: String = ""
  
  @GraphStored
  var age: Int = 0
}

// Usage
let profile = UserProfile()
profile.name = "John Doe"
profile.email = "john@example.com"
profile.age = 30

Persistent Values

Stored intentionally owns only in-memory state. Use @GraphUserDefault when a model value should synchronize with UserDefaults:

final class AppSettings {
  @GraphUserDefault("userName")
  var userName: String = ""

  @GraphComputed
  var welcomeMessage: String

  init() {
    $welcomeMessage = .init { [$userName] _ in
      let name = $userName.wrappedValue
      return name.isEmpty ? "Welcome!" : "Welcome, \(name)!"
    }
  }
}

$userName is the reference-identity GraphUserDefault<String> handle. Reading its value delegates to an internal Stored<String> node, so dependency tracking is identical to an in-memory @GraphStored property. See doc:UserDefaults for store injection, suites, and custom value types.

Characteristics of Stored Nodes

  • Mutable: Values can be changed from outside
  • Source Nodes: They don't depend on other nodes
  • Change Propagation: When their value changes, all dependent nodes are notified
  • Observable: They integrate with SwiftUI and other observation systems

Graph Transactions

Use withGraphTransaction when a synchronous operation must publish several Stored assignments without exposing a partial commit:

withGraphTransaction {
  profile.$name.wrappedValue = "Taylor"
  profile.$age.wrappedValue = 30

  // Reads in this scope see both staged assignments.
  print(profile.$name.wrappedValue)
}

The outermost call stages assignments in each Stored node. Its body reads the latest staged value, while other threads continue to read the previously committed graph. When the body returns normally, StateGraph snapshots affected Observation nodes after current readers finish, then invokes the existing willSet delivery path before publication. A synchronously delivered handler on the committing thread reads the complete staged snapshot while other threads still read the old committed graph. All final values are then installed before StateGraph tracking invalidation and Observation's didSet delivery. Other writers wait until that work finishes, and readers briefly wait during publication so a computed value cannot observe a partial commit.

Stored.onDidSet(_:) and @GraphStored property observers remain assignment observers. They run synchronously for every staged assignment, including repeated or comparator-equivalent assignments. willSet reads the preceding staged snapshot; didSet and Stored.onDidSet(_:) read the newly staged value. Ordinary Stored assignments made by any of these observers join the same transaction. A rollback discards those assignments but cannot undo other side effects already performed by the observer.

Each Stored value uses its existing comparator and graph notification pipeline once at commit, comparing the committed old value with the final staged value.

The API is synchronous and thread-local; it does not cross await, task creation, or executor hops. Nested calls join the outer transaction rather than creating savepoints. An inner error that the outer body catches leaves its staged assignments in place; only an error that escapes the outermost call rolls all staged Stored assignments back.

Transaction-time Computed reads are evaluated from the staged Stored values without updating the committed cache or dependency edges. A mutation made by a synchronously delivered Observation handler during commit joins a following commit batch: it is immediately readable by later synchronous handlers on the committing thread, and every resulting batch is published before the outer call returns. These handlers run while other threads' graph writes remain suspended, but neither a comparator nor a handler may wait synchronously for another thread to finish a graph write. Handlers that existing tracking APIs schedule asynchronously do not inherit the thread-local transaction. The same applies when existing Observation delivery hops to MainActor: that handler reads the coherent committed snapshot current when it runs rather than transaction-local staged storage.

Rollback cannot undo side effects outside ordinary Stored assignment. For example, mutating a property through reference storage reachable from a Stored value changes that object immediately and is not reversible by the transaction. This includes a class stored directly or as an entity in a value-semantic collection. Stored.unsafeModify(_:) also changes committed storage directly and bypasses transaction staging and notifications.

Do not mutate GraphUserDefault, databases, or other externally committed sources inside withGraphTransaction. Their persistence, locking, notification, and rollback semantics are outside this Stored-level API. Likewise, accessing an externally locked store from the body while another thread mutates it is outside the transaction's lock-order guarantees.

Keep both committed and transaction-local Computed descriptor computation and equality free of graph mutations. StateGraph marks these closures as read-only and diagnoses violations in DEBUG builds. Non-DEBUG builds omit that tracking, so a violation remains unsupported rather than carrying runtime diagnostic overhead. Dependency cycles, including direct self-read, remain unsupported because the graph must be a DAG.

An ordinary immediate assignment evaluates its Stored comparator and delivers Observation willSet outside the physical node lock. A coordinator-owned logical reservation keeps another writer from changing the same node before publication, while readers can still observe the old value and writers to other nodes can proceed. Comparators are read-only graph operations. Do not begin a transaction from Observation willSet or Stored.unsafeModify(_:); start it from onDidSet(_:) or another post-mutation callback instead.

Computed Value Nodes

Computed nodes derive their values from other nodes and automatically update when their dependencies change. They represent the "derived state" in your application.

Basic Usage

let firstName = Stored(wrappedValue: "John")
let lastName = Stored(wrappedValue: "Doe")

// This computed node depends on firstName and lastName
let fullName = Computed { _ in
    "\(firstName.wrappedValue) \(lastName.wrappedValue)"
}

print(fullName.wrappedValue) // "John Doe"

// When a dependency changes, the computed value updates automatically
firstName.wrappedValue = "Jane"
print(fullName.wrappedValue) // "Jane Doe"

Using the @GraphComputed Macro

For properties in classes, use the @GraphComputed macro:

final class PersonViewModel {
  @GraphStored
  var firstName: String = ""
  
  @GraphStored
  var lastName: String = ""
  
  @GraphComputed
  var fullName: String
  
  @GraphComputed
  var initials: String
  
  init() {
    // Define how fullName is computed
    self.$fullName = .init { [$firstName, $lastName] _ in
      "\($firstName.wrappedValue) \($lastName.wrappedValue)"
    }
    
    // Define how initials is computed
    self.$initials = .init { [$firstName, $lastName] _ in
      let first = $firstName.wrappedValue.first?.uppercased() ?? ""
      let last = $lastName.wrappedValue.first?.uppercased() ?? ""
      return "\(first)\(last)"
    }
  }
}

Characteristics of Computed Nodes

  • Read-Only: Their values cannot be set directly
  • Lazy: Values are computed only when accessed
  • Cached: Results are cached until dependencies change
  • Dependent: They automatically track which nodes they depend on
  • Efficient: Only recalculate when necessary

Dependency Tracking

The power of Swift State Graph lies in its automatic dependency tracking system:

How Dependencies Are Tracked

  1. Access Detection: When a computed node accesses another node's value, a dependency is automatically recorded
  2. Change Notification: When a node's value changes, all dependent nodes are marked as "potentially dirty"
  3. Lazy Recalculation: When a potentially dirty node's value is accessed, it recalculates its value first

Dependency Declaration

In computed properties, you should capture dependencies in the closure's capture list:

// ✅ Correct: Dependencies explicitly captured
self.$computed = .init { [$dependency1, $dependency2] _ in
  $dependency1.wrappedValue + $dependency2.wrappedValue
}

// ❌ Incorrect: Dependencies not captured (may not be tracked)
self.$computed = .init { _ in
  dependency1 + dependency2  // Global access - not tracked
}

Cascade Updates

Dependencies form a graph, and changes cascade through it automatically:

final class ShoppingCartViewModel {
  @GraphStored
  var items: [CartItem] = []
  
  @GraphStored
  var taxRate: Double = 0.08
  
  @GraphComputed
  var subtotal: Double
  
  @GraphComputed
  var tax: Double
  
  @GraphComputed
  var total: Double
  
  init() {
    // subtotal depends on items
    self.$subtotal = .init { [$items] _ in
      $items.wrappedValue.reduce(0) { \$0 + \$1.price }
    }
    
    // tax depends on subtotal and taxRate
    self.$tax = .init { [$subtotal, $taxRate] _ in
      $subtotal.wrappedValue * $taxRate.wrappedValue
    }
    
    // total depends on subtotal and tax
    self.$total = .init { [$subtotal, $tax] _ in
      $subtotal.wrappedValue + $tax.wrappedValue
    }
  }
}

// When items change, subtotal → tax → total all update automatically

The Reactive System

This creates a reactive system where:

  • Changes Flow Automatically: Update one value, and all derived values update
  • Minimal Computation: Only values that actually changed are recalculated
  • No Manual Synchronization: No need to remember to call update methods
  • Declarative: Your code expresses what values depend on, not how to update them

Reactive Processing with withGraphTrackingGroup

Beyond simple observation, Swift State Graph provides withGraphTrackingGroup for reactive processing where code executes immediately and re-executes whenever accessed nodes change. This enables building reactive side effects that adapt to runtime conditions.

How It Works

withGraphTrackingGroup provides Computed-like behavior for side effects:

let subscription = withGraphTracking {
  withGraphTrackingGroup {
    // This code runs initially and re-runs when any accessed node changes
    
    // Always tracked
    print("Count: \(counter.wrappedValue)")
    
    // Conditionally tracked - only when enabled
    if featureEnabled.wrappedValue {
      print("Feature data: \(featureData.wrappedValue)")
    }
  }
}

Dynamic Dependency Tracking

Unlike static dependency declaration, withGraphTrackingGroup tracks dependencies dynamically:

final class DataProcessor {
  @GraphStored var isEnabled: Bool = false
  @GraphStored var data: [Item] = []
  @GraphStored var user: User?
}

let processor = DataProcessor()

let subscription = withGraphTracking {
  withGraphTrackingGroup {
    // Only process when enabled
    if processor.isEnabled {
      let items = processor.data
      print("Processing \(items.count) items")
      
      // Only track premium features for premium users
      if processor.user?.isPremium == true {
        performPremiumAnalysis(items)
      } else {
        performBasicAnalysis(items)
      }
    }
    
    // Always update UI
    updateItemCount(processor.data.count)
  }
}

Key Characteristics

  • Immediate Execution: Runs synchronously on first call
  • Automatic Re-execution: Re-runs when any tracked node changes
  • Conditional Dependencies: Only tracks nodes actually accessed in the current execution
  • Actor Isolation: Preserves the calling context's actor isolation
  • Side Effect Focused: For operations rather than value computation

Use Cases

  1. Feature Flags: Enable/disable functionality based on runtime conditions
  2. User Permissions: Conditionally execute code based on user state
  3. Adaptive Processing: Change behavior based on data characteristics
  4. Performance Optimization: Avoid tracking expensive nodes when not needed

Important Notes

  • Must be called within a withGraphTracking scope
  • The handler should perform side effects, not return values
  • All subscriptions are cleaned up when the returned cancellable is cancelled
  • See doc:Tracking-Registrations for the lifecycle of the one-shot registrations used by each execution

Memory Management

Swift State Graph uses weak references and automatic cleanup to prevent memory leaks:

  • Weak Node References: Dependency edges use weak references
  • Automatic Cleanup: When nodes are deallocated, they clean up their edges
  • Subscription Management: Observation subscriptions are automatically managed

Type Safety

The system is fully type-safe:

  • Compile-Time Checking: Dependencies are checked at compile time
  • Generic Types: Full support for Swift's generic system
  • Optional Support: Proper handling of optional values

Performance Characteristics

Understanding the performance implications:

  • O(1) Access: Reading cached computed values is constant time
  • Lazy Evaluation: Expensive computations only run when needed
  • Batched Updates: Multiple changes can be batched together
  • Memory Efficient: Only stores what's necessary

Next Steps

Now that you understand the core concepts: