Pieces of app data that can be accessed from anywhere propagate changes reactively.
Recompute atom data and views only when truly needed, otherwise, it caches data until no longer used.
Successful compilation guarantees that dependency injection is ready.
SwiftUI Atom Properties offers practical capabilities to manage the complexity of modern apps. It effectively integrates the solution for both data-binding and dependency injection while allowing us to rapidly building an application.
Motivation
SwiftUI offers a simple and understandable data-binding solution with built-in property wrappers but is a little uneasy for building middle to large-scale production apps. As a typical example, view data can only be shared by pushing it up to a common ancestor. EnvironmentObject was hoped to be a solution to the problem, but it ended up with let us create a huge state-holder object being provided from the root of an app, so pure SwiftUI needs state-drilling from the root to descendants in any way, which not only makes code-splitting difficult but also causes gradual performance degradation due to the huge view-tree computation as the app grow up.
This library solves these problems by defining application data as distributed pieces called atoms, allowing data to be shared throughout the app as the source of truth. That said, an atom itself doesn’t have an internal state, but rather retrieves the associated state from the context in which they are used, and ensures that the app is testable. It manages a directed graph of atoms and propagates data changes transitively from upstream to downstream, such that it updates only the views that truly need update while preventing expensive data recomputation, resulting in effortlessly high performance and efficient memory use.
This approach guarantees the following principles:
Reactively reflects data changes.
Boilerplate-free interface where shared data has the same simple interface as SwiftUI built-ins.
Bind the atom to the view using @WatchState property wrapper so that it can obtain the value and write new values.
struct CountStepper: View {
@WatchState(CounterAtom())
var count
var body: some View {
Stepper(value: $count) {}
}
}
@Watch property wrapper obtains the atom value read-only. Now that the app can share the state among multiple views without passing it down through initializer.
struct CounterView: View {
@Watch(CounterAtom())
var count
var body: some View {
VStack {
Text("Count: \(count)")
CountStepper()
}
}
}
If you like the principles, see the sample apps and the basic tutorial to learn more about this library.
Examples
Counter Demonstrates the minimum app using this library.
Todo A simple todo app that has user interactions, showing how multiple atoms interact with each other.
The Movie DB Demonstrates practical usage which close to a real-world app, using TMDB API for asynchronous networking.
Map A simple but effective app that demonstrates how to wrap a framework in this library.
Voice Memo Demonstrates how to decompose and manage complex states and dependencies into compact atoms. Created to mimic the TCA’s example.
In this tutorial, we are going to create a simple todo app as an example. This app will support:
Create todo items
Edit todo items
Filter todo items
Every view that uses atom must have an AtomRoot somewhere in the ancestor. In SwiftUI lifecycle apps, it’s recommended to put it right under WindowGroup.
@main
struct TodoApp: App {
var body: some Scene {
WindowGroup {
AtomRoot {
TodoList()
}
}
}
}
First, define a todo structure and an enum to filter todo list, and declare state with StateAtom that represents a mutable value.
struct Todo {
var id: UUID
var text: String
var isCompleted: Bool
}
enum Filter: CaseIterable, Hashable {
case all, completed, uncompleted
}
struct TodosAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> [Todo] {
[]
}
}
struct FilterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Filter {
.all
}
}
The FilteredTodosAtom below represents the derived data that combines the above two atoms. You can think of derived data as the output of passing values to a pure function that derives a new value from the depending values.
When dependent data changes, the derived data reactively updates, and the output value is cached until it truly needs to be updated, so don’t need to worry about low performance due to the filter function being called each time the view recomputes.
struct FilteredTodosAtom: ValueAtom, Hashable {
func value(context: Context) -> [Todo] {
let filter = context.watch(FilterAtom())
let todos = context.watch(TodosAtom())
switch filter {
case .all: return todos
case .completed: return todos.filter(\.isCompleted)
case .uncompleted: return todos.filter { !$0.isCompleted }
}
}
}
To create a new todo item, you need to access to a writable value that update the value of TodosAtom we defined previously. We can use @WatchState property wrapper to obtain a read-write access to it.
struct TodoCreator: View {
@WatchState(TodosAtom())
var todos
@State
var text = ""
var body: some View {
HStack {
TextField("Enter your todo", text: $text)
Button("Add") {
todos.append(Todo(id: UUID(), text: text, isCompleted: false))
text = ""
}
}
}
}
Similarly, build a view to switch the value of FilterAtom. Get a Binding to the value exposed by @WatchState using $ prefix.
struct TodoFilters: View {
@WatchState(FilterAtom())
var current
var body: some View {
Picker("Filter", selection: $current) {
ForEach(Filter.allCases, id: \.self) { filter in
switch filter {
case .all: Text("All")
case .completed: Text("Completed")
case .uncompleted: Text("Uncompleted")
}
}
}
.pickerStyle(.segmented)
}
}
Next, create a view to display and edit individual todo items.
struct TodoItem: View {
@WatchState(TodosAtom())
var allTodos
@State
var text: String
@State
var isCompleted: Bool
let todo: Todo
init(todo: Todo) {
self.todo = todo
self._text = State(initialValue: todo.text)
self._isCompleted = State(initialValue: todo.isCompleted)
}
var index: Int {
allTodos.firstIndex { $0.id == todo.id }!
}
var body: some View {
Toggle(isOn: $isCompleted) {
TextField("Todo", text: $text) {
allTodos[index].text = text
}
}
.onChange(of: isCompleted) { isCompleted in
allTodos[index].isCompleted = isCompleted
}
}
}
Use @Watch to obtain the value of FilteredTodosAtom read-only. It updates to any of the dependent atoms are propagated to this view, and it re-render the todo list. Finally, assemble the views we’ve created so far and complete.
struct TodoList: View {
@Watch(FilteredTodosAtom())
var filteredTodos
var body: some View {
List {
TodoCreator()
TodoFilters()
ForEach(filteredTodos, id: \.id) { todo in
TodoItem(todo: todo)
}
}
}
}
That is the basics for building apps using SwiftUI Atom Properties, but even asynchronous processes and more complex state management can be settled according to the same steps. See Guides section for more detail. Also, the Examples directory has several projects to explore concrete usage.
Guides
This section introduces the available APIs and their uses. To look into the APIs in more detail, visit the API referrence.
Provides a store object which manages the state of atoms to view-tree through environment values. It must be the root of any views to manage atoms used throughout the application.
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
AtomRoot {
ExampleView()
}
}
}
}
Atoms
An atom represents a piece of state and is the source of truth for your app. It can also represent a derived data by combining and transforming one or more other atoms. Each atom does not actually have a global data inside, and retrieve values from the store provided by the AtomRoot. That’s why they can be accessed from anywhere, but never lose testability.
An atom and its value are associated using a unique key which is automatically defined if the atom conforms to Hashable, but you can also define it explicitly without Hashable.
struct UserNameAtom: StateAtom {
let userID: Int
var key: Int {
userID
}
func defaultValue(context: Context) -> String {
"Robert"
}
}
In order to provide the best interface and effective data-binding for the type of the resulting values, there are several variants of atoms as following.
class Contact: ObservableObject {
@Published var name = ""
@Published var age = 20
func haveBirthday() {
age += 1
}
}
struct ContactAtom: ObservableObjectAtom, Hashable {
func object(context: Context) -> Contact {
Contact()
}
}
struct ContactView: View {
@WatchStateObject(ContactAtom())
var contact
var body: some View {
VStack {
TextField("Enter your name", text: $contact.name)
Text("Age: \(contact.age)")
Button("Celebrate your birthday!") {
contact.haveBirthday()
}
}
}
}
Description
Summary
Instantiates an observable object.
Output
T: ObservableObject
Use Case
Mutable complex state object
Modifiers
Modifiers can be applied to an atom to produce a different versions of the original atom to make it more coding friendly or to reduce view re-computation for performance optimization.
struct CountAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
12345
}
}
struct CountDisplayView: View {
@Watch(CountAtom().select(\.description))
var description // : String
var body: some View {
Text(description)
}
}
Description
Summary
Selects a partial property with the specified key path from the original atom. The selected property doesn’t notify updates if the new value is equivalent to the old value.
Output
T: Equatable
Compatible
All atoms types. The selected property must be Equatable compliant.
struct FetchWeatherAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> Weather {
try await fetchWeather()
}
}
struct WeatherReportView: View {
@Watch(FetchWeatherAtom().phase)
var weatherPhase // : AsyncPhase<Weather, Error>
var body: some View {
switch weatherPhase {
case .suspending:
Text("Loading.")
case .success(let weather):
Text("It's \(weather.description) now!")
case .failure:
Text("Failed to get weather data.")
}
}
}
Description
Summary
Converts the Task that the original atom provides into AsyncPhase.
Output
AsyncPhase<T, E: Error>
Compatible
TaskAtom, ThrowingTaskAtom
Use Case
Consume asynchronous result as AsyncPhase
Property Wrappers
The following property wrappers are used to bind atoms to view and recompute the view with data changes. By retrieving the atom through these property wrappers, the internal system marks the atom as in-use and the values are cached until that view is dismantled.
struct UserNameAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> String {
"Jim"
}
}
struct UserNameInputView: View {
@WatchState(UserNameAtom())
var name
var body: some View {
VStack {
TextField("User name", text: $name)
Button("Clear") {
name = ""
}
}
}
}
Description
Summary
This property wrapper is read-write as the same interface as @State. It recomputes the view with data changes. You can get a Binding to the value using $ prefix.
class Counter: ObservableObject {
@Published var count = 0
func plus(_ value: Int) {
count += value
}
}
struct CounterAtom: ObservableObjectAtom, Hashable {
func object(context: Context) -> Counter {
Counter()
}
}
struct CounterView: View {
@WatchStateObject(CounterObjectAtom())
var counter
var body: some View {
VStack {
Text("Count: \(counter.count)")
Stepper(value: $counter.count) {}
Button("+100") {
counter.plus(100)
}
}
}
}
Description
Summary
This property wrapper has the same interface as @StateObject and @ObservedObject. It recomputes the view when the observable object updates. You can get a Binding to one of the observable object’s properties using $ prefix.
struct FetchBookAtom: ThrowingTaskAtom, Hashable {
let id: Int
func value(context: Context) async throws -> Book {
try await fetchBook(id: id)
}
}
struct BookView: View {
@ViewContext
var context
let id: Int
var body: some View {
let task = context.watch(FetchBookAtom(id: id))
Suspense(task) { book in
Text(book.content)
} suspending: {
ProgressView()
}
}
}
Unlike the property wrappers described the above, this property wrapper is not intended to bind single atom. It provides an AtomViewContext to the view, allowing for more functional control of atoms. For instance, the following controls can only be done through the context.
refresh(_:) operator that to reset an asynchronous atom value and wait for its completion.
await context.refresh(FetchMoviesAtom())
reset(_:) operator that to clear the current atom value.
context.reset(CounterAtom())
The context also provides a flexible solution for passing dynamic parameters to atom’s initializer. See Context section for more detail.
Context
Context is a structure for using and interacting with atom values from views or other atoms.
Reset an atom to the default value or a first output.
There are the following types context as different contextual environments. The APIs described in each section below are their own specific functionality depending on the environment in which it is used, in addition to the above common APIs.
A context passed as a parameter to the primary function of each atom type. This context type has a coordinator property that preserves an instance from the time an atom is used and initialized until it is unused and cleaned up, so it can be used to cache values or as a lifecycle for an atom.
KeepAlive allows the atom to preserve its data even if it’s no longer watched to from anywhere. In the example case below, once master data is obtained from the server, it can be cached in memory until the app process terminates.
Suspense awaits the resulting value of the given Task and displays the content depending on its phase. Optionally, you can pass suspending content to be displayed until the task completes, and pass catch content to be displayed if the task fails.
struct NewsView: View {
@Watch(LatestNewsAtom())
var newsTask: Task<News, Error>
var body: some View {
Suspense(newsTask) { news in
Text(news.content)
} suspending: {
ProgressView()
} catch: { error in
Text(error.localizedDescription)
}
}
}
Override
Values and states defined by atoms can be overridden in root or in any scope. If you override an atom in AtomRoot, it will override the values throughout the app, which is useful for dependency injection. In case you want to override an atom only in a limited scope, you might like to use AtomScope instead to override as it substitutes the atom value only in that scope, which can be useful for injecting dependencies that are needed only for the scope or overriding state in certain views.
AtomRoot {
VStack {
CountStepper()
AtomScope {
CountDisplay()
}
.override(CounterAtom()) { _ in
// Overrides the count to be 456 only for the display content.
456
}
}
}
.override(CounterAtom()) { _ in
// Overrides the count to be 123 throughout the app.
123
}
See Testing section for details on dependency injection on unit tests.
Testing
This library naturally integrates dependency injection and data-binding to provide a comprehensive means of testing. It allows you to test per small atom such that you can keep writing simple test cases per smallest unit of state without compose all states into a huge object and supposing complex integration test scenarios. In order to fully test your app, this library guarantees the following principles:
Hermetic environment that no data is shared between test cases.
Dependencies are replaceable with any of mock/stub/fake/spy per test case.
Test cases can reproduce any possible scenarios at the view-layer.
In the test case, you first create an AtomTestContext instance that behaves similarly to other context types. The context allows for flexible reproduction of expected scenarios for testing using the control functions described in the Context section. In addition, it’s able to replace the atom value with test-friendly dependencies with override function. It helps you to write a reproducible & stable testing. Since atom needs to be used from the main actor to guarantee thread-safety, XCTestCase class that to test atoms should have @MainActor attribute.
Click to expand the classes to be tested
struct Book: Equatable {
var title: String
var isbn: String
}
protocol APIClientProtocol {
func fetchBook(isbn: String) async throws -> Book
}
struct APIClient: APIClientProtocol {
func fetchBook(isbn: String) async throws -> Book {
... // Networking logic.
}
}
class MockAPIClient: APIClientProtocol {
var response: Book?
func fetchBook(isbn: String) async throws -> Book {
guard let response else {
throw URLError(.unknown)
}
return response
}
}
struct APIClientAtom: ValueAtom, Hashable {
func value(context: Context) -> APIClientProtocol {
APIClient()
}
}
struct FetchBookAtom: ThrowingTaskAtom, Hashable {
let isbn: String
func value(context: Context) async throws -> Book {
let api = context.watch(APIClientAtom())
return try await api.fetchBook(isbn: isbn)
}
}
@MainActor
class FetchBookTests: XCTestCase {
func testFetch() async throws {
let context = AtomTestContext()
let api = MockAPIClient()
// Override the atom value with the mock instance.
context.override(APIClientAtom()) { _ in
api
}
let expected = Book(title: "A book", isbn: "ISBN000–0–0000–0000–0")
// Inject the expected response to the mock.
api.response = expected
let book = try await context.watch(FetchBookAtom(isbn: "ISBN000–0–0000–0000–0")).value
XCTAssertEqual(book, expected)
}
}
Debugging
This library defines a Directed Acyclic Graph (DAG) internally to centrally manage atom states, making it easy to analyze its dependencies and where they are (or are not) being used. There are the following two ways to get a Snapshot of the dependency graph at a given point in time.
The first is to get Snapshot through @ViewContext. This API is suitable for obtaining and analyzing debugging information on demand.
@ViewContext
var context
var debugButton: some View {
Button("Dump dependency graph") {
let snapshot = context.snapshot()
print(snapshot.graphDescription())
}
}
Or, you can observe all updates of atoms and always continue to receive Snapshots at that point in time through observe(_:) modifier of AtomRoot or AtomScope. Note that observing in AtomRoot will receive all atom updates that happened in the whole app, but observing in AtomScope will only receive atoms used in the descendant views.
AtomRoot {
HomeScreen()
}
.observe { snapshot in
print(snapshot.graphDescription())
}
@ViewContext also supports restoring the values of atoms and the dependency graph captured at a point in time in a retrieved snapshot and its dependency graph so that you can investigate what happend. The debugging technique is called time travel debugging, and the example application here demonstrates how it works.
@ViewContext
var context
@State
var snapshot: Snapshot?
var body: some View {
VStack {
Button("Capture") {
snapshot = context.snapshot()
}
Button("Restore") {
if let snapshot {
context.restore(snapshot)
}
}
}
}
In addition, graphDescription() method returns a string, that represents the dependencies graph and where they are used, as a String in graph description language DOT. This can be converted to an image using Graphviz, a graph visualization tool, to visually analyze information about the state of the application, as shown below.
Even in SwiftUI previews, the view must have an AtomRoot somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don’t need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.
struct NewsList_Preview: PreviewProvider {
static var previews: some View {
AtomRoot {
NewsList()
}
.override(APIClientAtom()) { _ in
StubAPIClient()
}
}
}
The read(_:) function is a way to get the data of an atom without having watch to and receiving future updates of it. It’s commonly used inside functions triggered by call-to-actions.
Dynamically initiate atom families
📖 Click to expand example code
struct FetchUserAtom: ThrowingTaskAtom {
let id: Int
// This atom can also conforms to `Hashable` in this case,
// but this example specifies the key explicitly.
var key: Int {
id
}
func value(context: Context) async throws -> Value {
try await fetchUser(id: id)
}
}
struct UserView: View {
let id: Int
@ViewContext
var context
var body: some View {
let task = context.watch(FetchUserAtom(id: id))
Suspense(task) { user in
VStack {
Text("Name: \(user.name)")
Text("Age: \(user.age)")
}
}
}
}
Each atom must have a unique key to be uniquely associated with its value. As described in the Atoms section, it is automatically synthesized by conforming to Hashable, but with explicitly specifying a key allowing you to pass arbitrary external parameters to the atom. It is commonly used, for example, to retrieve user information associated with a dynamically specified ID from a server.
You can pass a context to your object and interact with other atoms at any asynchronous timing. However, in that case, when the watch is called, it end up with the object instance itself will be re-created with fresh data. Therefore, you can explicitly prevent the use of the watch by passing it as AtomContext type.
All atom types can optionally implement updated(newValue:oldValue:context: method to manage arbitrary side-effects of value updates, such as state persistence, state synchronization, logging, and etc. In the above example, the initial state of the atom is retrieved from UserDefaults, and when the user updates the state, the value is reflected into UserDefaults as a side effect.
Dealing with Known SwiftUI Bugs
Modal presentation causes assertionFailure when dismissing it (Fixed in iOS15)
💡 Click to expand workaround
struct RootView: View {
@State
var isPresented = false
@ViewContext
var context
var body: some View {
VStack {
Text("Example View")
}
.sheet(isPresented: $isPresented) {
AtomScope(context) {
MailView()
}
}
}
}
Unfortunately, SwiftUI has a bug in iOS14 or lower where the EnvironmentValue is removed from a screen presented with .sheet just before dismissing it. Since this library is designed based on EnvironmentValue, this bug end up triggering the friendly assertionFailure that is added so that developers can easily aware of forgotten AtomRoot implementation. As a workaround, AtomScope has the ability to explicitly inherit the store through AtomViewContext from the parent view.
Some SwiftUI modifiers cause memory leak (Fixed in iOS16)
💡 Click to expand workaround
@ViewContext
var context
...
.refreshable { [context] in
await context.refresh(FetchDataAtom())
}
@State
var isShowingSearchScreen = false
...
.onSubmit { [$isShowingSearchScreen] in
$isShowingSearchScreen.wrappedValue = true
}
In iOS 15 or lower, some modifiers in SwiftUI seem to cause an internal memory leak if it captures self implicitly or explicitly. To avoid that bug, make sure that self is not captured when using those modifiers. Below are the list of modifiers I found that cause memory leaks:
SwiftUI Atom Properties
A reactive data-binding and dependency injection libraryfor SwiftUI x Concurrency
📔 API Reference
Introduction
Dependency Injection
SwiftUI Atom Properties offers practical capabilities to manage the complexity of modern apps. It effectively integrates the solution for both data-binding and dependency injection while allowing us to rapidly building an application.
Motivation
SwiftUI offers a simple and understandable data-binding solution with built-in property wrappers but is a little uneasy for building middle to large-scale production apps. As a typical example, view data can only be shared by pushing it up to a common ancestor.
EnvironmentObject was hoped to be a solution to the problem, but it ended up with let us create a huge state-holder object being provided from the root of an app, so pure SwiftUI needs state-drilling from the root to descendants in any way, which not only makes code-splitting difficult but also causes gradual performance degradation due to the huge view-tree computation as the app grow up.
This library solves these problems by defining application data as distributed pieces called atoms, allowing data to be shared throughout the app as the source of truth. That said, an atom itself doesn’t have an internal state, but rather retrieves the associated state from the context in which they are used, and ensures that the app is testable.
It manages a directed graph of atoms and propagates data changes transitively from upstream to downstream, such that it updates only the views that truly need update while preventing expensive data recomputation, resulting in effortlessly high performance and efficient memory use.
This approach guarantees the following principles:
Quick Overview
To get a feel for this library, let’s first look at the state management for a tiny counter app.
The
CounterAtom
in the example below represents the shared data of a mutable count value.Bind the atom to the view using
@WatchState
property wrapper so that it can obtain the value and write new values.@Watch
property wrapper obtains the atom value read-only.Now that the app can share the state among multiple views without passing it down through initializer.
If you like the principles, see the sample apps and the basic tutorial to learn more about this library.
Examples
Demonstrates the minimum app using this library.
A simple todo app that has user interactions, showing how multiple atoms interact with each other.
Demonstrates practical usage which close to a real-world app, using TMDB API for asynchronous networking.
A simple but effective app that demonstrates how to wrap a framework in this library.
Demonstrates how to decompose and manage complex states and dependencies into compact atoms. Created to mimic the TCA’s example.
A simple demo that demonstrates how to do time travel debugging with this library.
Each example has test target to show how to test your atoms with dependency injection as well.
Open
Examples/App.xcodeproj
and play around with it!Getting Started
Requirements
Installation
The module name of the package is
Atoms
. Choose one of the instructions below to install and add the following import statement to your source code.Xcode Package Dependency
From Xcode menu:
File
>Swift Packages
>Add Package Dependency
Swift Package Manager
In your
Package.swift
file, first add the following to the packagedependencies
:And then, include “Atoms” as a dependency for your target:
Documentation
Basic Tutorial
In this tutorial, we are going to create a simple todo app as an example. This app will support:
Every view that uses atom must have an
AtomRoot
somewhere in the ancestor. In SwiftUI lifecycle apps, it’s recommended to put it right underWindowGroup
.First, define a todo structure and an enum to filter todo list, and declare state with
StateAtom
that represents a mutable value.The
FilteredTodosAtom
below represents the derived data that combines the above two atoms. You can think of derived data as the output of passing values to a pure function that derives a new value from the depending values.When dependent data changes, the derived data reactively updates, and the output value is cached until it truly needs to be updated, so don’t need to worry about low performance due to the filter function being called each time the view recomputes.
To create a new todo item, you need to access to a writable value that update the value of
TodosAtom
we defined previously. We can use@WatchState
property wrapper to obtain a read-write access to it.Similarly, build a view to switch the value of
FilterAtom
. Get aBinding
to the value exposed by@WatchState
using$
prefix.Next, create a view to display and edit individual todo items.
Use
@Watch
to obtain the value ofFilteredTodosAtom
read-only. It updates to any of the dependent atoms are propagated to this view, and it re-render the todo list.Finally, assemble the views we’ve created so far and complete.
That is the basics for building apps using SwiftUI Atom Properties, but even asynchronous processes and more complex state management can be settled according to the same steps.
See Guides section for more detail. Also, the Examples directory has several projects to explore concrete usage.
Guides
This section introduces the available APIs and their uses.
To look into the APIs in more detail, visit the API referrence.
AtomRoot
Provides a store object which manages the state of atoms to view-tree through environment values.
It must be the root of any views to manage atoms used throughout the application.
Atoms
An atom represents a piece of state and is the source of truth for your app. It can also represent a derived data by combining and transforming one or more other atoms.
Each atom does not actually have a global data inside, and retrieve values from the store provided by the
AtomRoot
. That’s why they can be accessed from anywhere, but never lose testability.An atom and its value are associated using a unique
key
which is automatically defined if the atom conforms toHashable
, but you can also define it explicitly without Hashable.In order to provide the best interface and effective data-binding for the type of the resulting values, there are several variants of atoms as following.
ValueAtom
📖 Click to expand example code
T
StateAtom
📖 Click to expand example code
T
TaskAtom
📖 Click to expand example code
Task
from the givenasync
function.Task<T, Never>
ThrowingTaskAtom
📖 Click to expand example code
Task
from the givenasync throws
function.Task<T, Error>
AsyncSequenceAtom
📖 Click to expand example code
AsyncPhase
value that represents asynchronous, sequential elements of the givenAsyncSequence
.AsyncPhase<T, Error>
PublisherAtom
📖 Click to expand example code
AsyncPhase
value that represents sequence of values of the givenPublisher
.AsyncPhase<T, E: Error>
ObservableObjectAtom
📖 Click to expand example code
T: ObservableObject
Modifiers
Modifiers can be applied to an atom to produce a different versions of the original atom to make it more coding friendly or to reduce view re-computation for performance optimization.
select
📖 Click to expand example code
T: Equatable
Equatable
compliant.changes
📖 Click to expand example code
T: Equatable
Equatable
compliant value.phase
📖 Click to expand example code
Task
that the original atom provides intoAsyncPhase
.AsyncPhase<T, E: Error>
TaskAtom
,ThrowingTaskAtom
AsyncPhase
Property Wrappers
The following property wrappers are used to bind atoms to view and recompute the view with data changes.
By retrieving the atom through these property wrappers, the internal system marks the atom as in-use and the values are cached until that view is dismantled.
@Watch
📖 Click to expand example code
@State
or@Environment
, but is always read-only. It recomputes the view with value changes.@WatchState
📖 Click to expand example code
@State
. It recomputes the view with data changes. You can get aBinding
to the value using$
prefix.StateAtom
@WatchStateObject
📖 Click to expand example code
@StateObject
and@ObservedObject
. It recomputes the view when the observable object updates. You can get aBinding
to one of the observable object’s properties using$
prefix.ObservableObjectAtom
@ViewContext
📖 Click to expand example code
Unlike the property wrappers described the above, this property wrapper is not intended to bind single atom. It provides an
AtomViewContext
to the view, allowing for more functional control of atoms.For instance, the following controls can only be done through the context.
refresh(_:)
operator that to reset an asynchronous atom value and wait for its completion.reset(_:)
operator that to clear the current atom value.The context also provides a flexible solution for passing dynamic parameters to atom’s initializer. See Context section for more detail.
Context
Context is a structure for using and interacting with atom values from views or other atoms.
There are the following types context as different contextual environments.
The APIs described in each section below are their own specific functionality depending on the environment in which it is used, in addition to the above common APIs.
AtomViewContext
📖 Click to expand example code
A context available through the
@ViewContext
property wrapper when using atoms from a view.AtomTransactionContext
📖 Click to expand example code
A context passed as a parameter to the primary function of each atom type.
This context type has a
coordinator
property that preserves an instance from the time an atom is used and initialized until it is unused and cleaned up, so it can be used to cache values or as a lifecycle for an atom.AtomTestContext
📖 Click to expand example code
A context that can simulate any scenarios in which atoms are used from a view or another atom and provides a comprehensive means of testing.
KeepAlive
KeepAlive
allows the atom to preserve its data even if it’s no longer watched to from anywhere.In the example case below, once master data is obtained from the server, it can be cached in memory until the app process terminates.
Suspense
Suspense
awaits the resulting value of the givenTask
and displays the content depending on its phase.Optionally, you can pass
suspending
content to be displayed until the task completes, and passcatch
content to be displayed if the task fails.Override
Values and states defined by atoms can be overridden in root or in any scope.
If you override an atom in AtomRoot, it will override the values throughout the app, which is useful for dependency injection. In case you want to override an atom only in a limited scope, you might like to use AtomScope instead to override as it substitutes the atom value only in that scope, which can be useful for injecting dependencies that are needed only for the scope or overriding state in certain views.
See Testing section for details on dependency injection on unit tests.
Testing
This library naturally integrates dependency injection and data-binding to provide a comprehensive means of testing. It allows you to test per small atom such that you can keep writing simple test cases per smallest unit of state without compose all states into a huge object and supposing complex integration test scenarios.
In order to fully test your app, this library guarantees the following principles:
In the test case, you first create an
AtomTestContext
instance that behaves similarly to other context types. The context allows for flexible reproduction of expected scenarios for testing using the control functions described in the Context section.In addition, it’s able to replace the atom value with test-friendly dependencies with
override
function. It helps you to write a reproducible & stable testing.Since atom needs to be used from the main actor to guarantee thread-safety,
XCTestCase
class that to test atoms should have@MainActor
attribute.Click to expand the classes to be tested
Debugging
This library defines a Directed Acyclic Graph (DAG) internally to centrally manage atom states, making it easy to analyze its dependencies and where they are (or are not) being used.
There are the following two ways to get a Snapshot of the dependency graph at a given point in time.
The first is to get
Snapshot
through @ViewContext. This API is suitable for obtaining and analyzing debugging information on demand.Or, you can observe all updates of atoms and always continue to receive
Snapshots
at that point in time throughobserve(_:)
modifier of AtomRoot or AtomScope.Note that observing in
AtomRoot
will receive all atom updates that happened in the whole app, but observing inAtomScope
will only receive atoms used in the descendant views.@ViewContext
also supports restoring the values of atoms and the dependency graph captured at a point in time in a retrieved snapshot and its dependency graph so that you can investigate what happend.The debugging technique is called time travel debugging, and the example application here demonstrates how it works.
In addition, graphDescription() method returns a string, that represents the dependencies graph and where they are used, as a String in graph description language DOT.
This can be converted to an image using Graphviz, a graph visualization tool, to visually analyze information about the state of the application, as shown below.
Preview
Even in SwiftUI previews, the view must have an
AtomRoot
somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don’t need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.Advanced Usage
Use atoms without watching
📖 Click to expand example code
The
read(_:)
function is a way to get the data of an atom without having watch to and receiving future updates of it. It’s commonly used inside functions triggered by call-to-actions.Dynamically initiate atom families
📖 Click to expand example code
Each atom must have a unique
key
to be uniquely associated with its value. As described in the Atoms section, it is automatically synthesized by conforming toHashable
, but with explicitly specifying akey
allowing you to pass arbitrary external parameters to the atom. It is commonly used, for example, to retrieve user information associated with a dynamically specified ID from a server.Use atoms from objects
📖 Click to expand example code
You can pass a context to your object and interact with other atoms at any asynchronous timing. However, in that case, when the
watch
is called, it end up with the object instance itself will be re-created with fresh data. Therefore, you can explicitly prevent the use of thewatch
by passing it asAtomContext
type.Manage side-effects
📖 Click to expand example code
All atom types can optionally implement
updated(newValue:oldValue:context:
method to manage arbitrary side-effects of value updates, such as state persistence, state synchronization, logging, and etc.In the above example, the initial state of the atom is retrieved from UserDefaults, and when the user updates the state, the value is reflected into UserDefaults as a side effect.
Dealing with Known SwiftUI Bugs
Modal presentation causes assertionFailure when dismissing it (Fixed in iOS15)
💡 Click to expand workaround
Unfortunately, SwiftUI has a bug in iOS14 or lower where the
EnvironmentValue
is removed from a screen presented with.sheet
just before dismissing it. Since this library is designed based onEnvironmentValue
, this bug end up triggering the friendlyassertionFailure
that is added so that developers can easily aware of forgottenAtomRoot
implementation.As a workaround,
AtomScope
has the ability to explicitly inherit the store throughAtomViewContext
from the parent view.Some SwiftUI modifiers cause memory leak (Fixed in iOS16)
💡 Click to expand workaround
In iOS 15 or lower, some modifiers in SwiftUI seem to cause an internal memory leak if it captures
self
implicitly or explicitly. To avoid that bug, make sure thatself
is not captured when using those modifiers.Below are the list of modifiers I found that cause memory leaks:
refreshable(action:)
onSubmit(of:_:)
Contributing
Any type of contribution is welcome! e.g.
Acknowledgements
License
MIT © Ryo Aoyama