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
- Access Detection: When a computed node accesses another node's value, a dependency is automatically recorded
- Change Notification: When a node's value changes, all dependent nodes are marked as "potentially dirty"
- 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
- Feature Flags: Enable/disable functionality based on runtime conditions
- User Permissions: Conditionally execute code based on user state
- Adaptive Processing: Change behavior based on data characteristics
- Performance Optimization: Avoid tracking expensive nodes when not needed
Important Notes
- Must be called within a
withGraphTrackingscope - 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:
- doc:Describing-Models - Learn to build complex reactive models
- doc:Advanced-Patterns - Explore advanced usage patterns
- doc:Performance-Optimization - Optimize your state graphs for performance