A graph-based reactive state management library for Swift. For managing external(escaping) state, inspired by the concepts of React, Jotai and Recoil.
import StateGraph
final class Counter {
@GraphStored var count: Int = 0
@GraphComputed var isEven: Bool
init() {
$isEven = .init { [$count] _ in
$count.wrappedValue % 2 == 0
}
}
}
// Usage
let counter = Counter()
counter.count = 5
print(counter.isEven) // false - automatically computedComputed properties automatically track their dependencies and update when source values change:
@GraphStored var firstName: String = "John"
@GraphStored var lastName: String = "Doe"
@GraphComputed var fullName: String
init() {
$fullName = .init { [$firstName, $lastName] _ in
"\($firstName.wrappedValue) \($lastName.wrappedValue)"
}
}
// Change firstName β fullName updates automaticallyNative integration with SwiftUI's reactive system and UIKit through tracking APIs:
// SwiftUI - just use the properties
struct CounterView: View {
let counter: Counter
var body: some View {
Text("\(counter.count)")
Button("Up") { counter.count += 1 }
}
}
// UIKit - use withGraphTracking
subscription = withGraphTracking {
withGraphTrackingGroup {
print(counter.count)
}
}Migrate from @Observable and gain automatic computed property updates:
// Before: Manual validation updates
@Observable class UserViewModel {
var name = ""
var isValid = false
func validate() { isValid = !name.isEmpty }
}
// After: Automatic reactivity
final class UserViewModel {
@GraphStored var name: String = ""
@GraphComputed var isValid: Bool
init() {
$isValid = .init { [$name] _ in !$name.wrappedValue.isEmpty }
}
}Persist a graph-aware value without changing the Stored primitive:
@GraphUserDefault("theme")
var theme: String = "light"The projected value, $theme, is the reference-identity
GraphUserDefault<String> handle. Its reads participate in graph dependency
tracking, and its writes persist to UserDefaults.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/VergeGroup/swift-state-graph.git", from: "1.0.0")
].target(
name: "YourTarget",
dependencies: ["StateGraph"]
)StateGraph and StateGraphNormalization are distributed as dynamic library
products. StateGraph owns process-wide tracking and transaction context, so one
process must load one shared StateGraph runtime. Dynamic products ensure that
multiple frameworks link to shared binaries instead of embedding independent
static copies.
The application target should embed the produced frameworks. Dependent dynamic frameworks should link them without embedding additional copies.
With Tuist's native Swift Package integration, use the default .runtime
dependency from each feature framework and .runtimeEmbedded once from the
final application target:
// Feature framework
.package(product: "StateGraph")
// Application
.package(product: "StateGraph", type: .runtimeEmbedded)
.package(product: "StateGraphNormalization", type: .runtimeEmbedded)StateGraphNormalization does not re-export StateGraph. Targets using graph
nodes together with normalization must depend on and import both products
explicitly:
import StateGraph
import StateGraphNormalizationMutable containers that hold values and notify dependents when changed:
@GraphStored var count: Int = 0
count = 10 // Dependents are notifiedRead-only values derived from other nodes. They:
- Automatically track dependencies
- Recalculate lazily when dependencies change
- Cache results until invalidated
@GraphComputed var doubled: Int
$doubled = .init { [$count] _ in $count.wrappedValue * 2 }Observe changes without SwiftUI:
let subscription = withGraphTracking {
withGraphTrackingGroup {
print("Changed: \(model.count), \(model.name)")
}
}Properties are automatically observed in SwiftUI views:
struct ItemListView: View {
let store: ItemStore
var body: some View {
List(store.items) { item in
Text(item.name)
}
TextField("New Item", text: store.$newItemName.binding)
}
}Use .graphTracking when a SwiftUI view needs to keep a withGraphTracking
subscription alive only while the view is visible:
struct AutoRefreshView: View {
let model: RefreshModel
var body: some View {
Toggle("Auto Refresh", isOn: model.$isEnabled.binding)
.graphTracking {
withGraphTrackingMap {
model.isEnabled
} onChange: { isEnabled in
if isEnabled {
model.start()
} else {
model.stop()
}
}
}
}
}Use GraphObject protocol for environment propagation:
final class AppState: GraphObject {
@GraphStored var user: User?
@GraphComputed var isLoggedIn: Bool
init() {
$isLoggedIn = .init { [$user] _ in $user.wrappedValue != nil }
}
}
// Inject
ContentView().environment(appState)
// Access
@Environment(AppState.self) private var appStateUse withGraphTracking to observe state changes:
class ViewController: UIViewController {
private let viewModel = ViewModel()
private var subscription: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
subscription = withGraphTracking {
withGraphTrackingMap {
viewModel.items
} onChange: { [weak self] items in
self?.tableView.reloadData()
}
}
}
}For detailed guides, see the Documentation:
- Core Concepts - Stored, Computed, and dependency tracking
- SwiftUI Integration - Bindings, GraphObject, Environment
- UIKit Integration - withGraphTracking patterns
- UserDefaults - Persistent values composed with Stored nodes
- Data Normalization - EntityStore for relational data
- Migration from Observable - Step-by-step guide
Groups and Maps can be nested. When a parent re-executes, nested children are automatically cancelled and recreated:
withGraphTrackingGroup {
if viewModel.featureEnabled {
withGraphTrackingGroup {
// Only tracked when featureEnabled is true
print("Feature value: \(viewModel.featureValue)")
}
}
}Share state between objects by assigning @GraphStored references:
final class ViewModel {
@GraphStored var items: [Item]
init(service: DataService) {
$items = service.$items // Shares the same node
}
}- Swift 6.0+
- iOS 17+ / macOS 14+
MIT License