Atomic approach state management and dependency injection for SwiftUI
Reactive Data Binding | Effective Caching | Compile Safe Dependency Injection |
---|---|---|
Pieces of app data that can be accessed from anywhere propagate changes reactively. | Cache data during in use and recompute only when truly needed. | Successful compilation guarantees that dependency injection is ready. |
Atoms offer a simple but practical capability to tackle the complexity of modern apps. It effectively integrates the solution for both state management and dependency injection while allowing us to rapidly build a robust and testable application.
Building state by compositing atoms automatically optimizes rendering based on its dependency graph. This solves the problem of performance degradation caused by extra re-render which occurs before you realize.
- Declare your primitive atoms.
struct CounterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
0
}
}
- Bind the atom to your views.
struct CountStepper: View {
@WatchState(CounterAtom())
var count
var body: some View {
Stepper(value: $count) {}
}
}
- Share state across views without passing a Binding.
struct CounterView: View {
@Watch(CounterAtom())
var count
var body: some View {
VStack {
Text("Count: \(count)")
CountStepper()
}
}
}
- 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. - Time Travel
A simple demo that demonstrates how to do time travel debugging with this library.
Each example has a test target too to demonstrate how to test your atoms with dependency injection.
Open Examples/Project.xcodeproj
and play around with it!
Minimum Version | |
---|---|
Swift | 5.10, 6.0 |
Xcode | 15.4, 16.0 |
iOS | 14.0 |
macOS | 11.0 |
tvOS | 14.0 |
watchOS | 7.0 |
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.
import Atoms
From Xcode menu: File
> Swift Packages...
https://github.com/ra1028/swiftui-atom-properties
In your Package.swift
file, first add the following to the package dependencies
:
.package(url: "https://github.com/ra1028/swiftui-atom-properties"),
And then, include "Atoms" as a dependency for your target:
.target(name: "<target>", dependencies: [
.product(name: "Atoms", package: "swiftui-atom-properties"),
]),
In this tutorial, we are going to create a simple todo app as an example. This app will support to create/edit/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 entity and an enum that represents filtering methods, and declare an atom with StateAtom
that represents a mutable state.
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 you 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
you defined previously.
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 a todo item. It also supports editing the item.
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
}
}
}
Finally, assemble the views you'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 Atoms, 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.
This section introduces the available APIs and their uses.
To look into the APIs in more detail, visit the API referrence.
This view allows descendant views to use atoms. It must be the root of any views throughout the application.
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
AtomRoot {
ExampleView()
}
}
}
}
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.
Description | |
---|---|
Summary | Provides a read-only value. |
Output | T |
Use Case | Computed property, Derived data, Dependency injection |
📖 Example
struct LocaleAtom: ValueAtom, Hashable {
func value(context: Context) -> Locale {
.current
}
}
struct LocaleView: View {
@Watch(LocaleAtom())
var locale
var body: some View {
Text(locale.identifier)
}
}
Description | |
---|---|
Summary | Provides a read-write data. |
Output | T |
Use Case | Mutable data, Derived data |
📖 Example
struct CounterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
0
}
}
struct CounterView: View {
@WatchState(CounterAtom())
var count
var body: some View {
Stepper("Count: \(count)", value: $count)
}
}
Description | |
---|---|
Summary | Initiates a non-throwing Task from the given async function. |
Output | Task<T, Never> |
Use Case | Non-throwing asynchronous operation e.g. Expensive calculation |
📖 Example
struct FetchUserAtom: TaskAtom, Hashable {
func value(context: Context) async -> User? {
await fetchUser()
}
}
struct UserView: View {
@Watch(FetchUserAtom())
var userTask
var body: some View {
Suspense(userTask) { user in
Text(user?.name ?? "Unknown")
}
}
}
Description | |
---|---|
Summary | Initiates a throwing Task from the given async throws function. |
Output | Task<T, any Error> |
Use Case | Throwing asynchronous operation e.g. API call |
📖 Example
struct FetchMoviesAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> [Movie] {
try await fetchMovies()
}
}
struct MoviesView: View {
@Watch(FetchMoviesAtom())
var moviesTask
var body: some View {
List {
Suspense(moviesTask) { movies in
ForEach(movies, id: \.id) { movie in
Text(movie.title)
}
} catch: { error in
Text(error.localizedDescription)
}
}
}
}
Description | |
---|---|
Summary | Provides an AsyncPhase value that represents a result of the given asynchronous throwable function. |
Output | AsyncPhase<T, E: Error> (AsyncPhase<T, any Error> in Swift 5) |
Use Case | Throwing or non-throwing asynchronous operation e.g. API call |
Note:
The typed throws feature introduced in Swift 6 allows the Failure
type of the produced AsyncPhase
to be specified as any type or even non-throwing, but in Swift 5 without it, the Failure
type is always be any Error
.
Here is a chart of the syntax in typed throws
and the type of resulting AsyncPhase
.
Syntax | Shorthand | Produced |
---|---|---|
throws(E) |
throws(E) |
AsyncPhase<T, E> |
throws(any Error) |
throws |
AsyncPhase<T, any Error> |
throws(Never) |
AsyncPhase<T, Never> |
📖 Example
struct FetchTrendingSongsAtom: AsyncPhaseAtom, Hashable {
func value(context: Context) async throws(FetchSongsError) -> [Song] {
try await fetchTrendingSongs()
}
}
struct TrendingSongsView: View {
@Watch(FetchTrendingSongsAtom())
var phase
var body: some View {
List {
switch phase {
case .success(let songs):
ForEach(songs, id: \.id) { song in
Text(song.title)
}
case .failure(.noData):
Text("There are no currently trending songs.")
case .failure(let error):
Text(error.localizedDescription)
}
}
}
}
Description | |
---|---|
Summary | Provides an AsyncPhase value that represents asynchronous, sequential elements of the given AsyncSequence . |
Output | AsyncPhase<T, any Error> |
Use Case | Handle multiple asynchronous values e.g. web-sockets |
📖 Example
struct NotificationAtom: AsyncSequenceAtom, Hashable {
let name: Notification.Name
func sequence(context: Context) -> NotificationCenter.Notifications {
NotificationCenter.default.notifications(named: name)
}
}
struct NotificationView: View {
@Watch(NotificationAtom(name: UIApplication.didBecomeActiveNotification))
var notificationPhase
var body: some View {
switch notificationPhase {
case .suspending, .failure:
Text("Unknown")
case .success:
Text("Active")
}
}
}
Description | |
---|---|
Summary | Provides an AsyncPhase value that represents sequence of values of the given Publisher . |
Output | AsyncPhase<T, E: Error> |
Use Case | Handle single or multiple asynchronous value(s) e.g. API call |
📖 Example
struct TimerAtom: PublisherAtom, Hashable {
func publisher(context: Context) -> AnyPublisher<Date, Never> {
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.eraseToAnyPublisher()
}
}
struct TimerView: View {
@Watch(TimerAtom())
var timerPhase
var body: some View {
if let date = timerPhase.value {
Text(date.formatted(date: .numeric, time: .shortened))
}
}
}
Description | |
---|---|
Summary | Instantiates an observable object. |
Output | T: ObservableObject |
Use Case | Mutable complex state object |
📖 Example
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()
}
}
}
}
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.
Description | |
---|---|
Summary | Derives a partial property with the specified key path from the original atom and prevent it from updating its downstream when its new value is equivalent to old value. |
Output | T: Equatable |
Compatible | All atoms types. The derived property must be Equatable compliant. |
Use Case | Performance optimization, Property scope restriction |
📖 Example
struct CountAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
12345
}
}
struct CountDisplayView: View {
@Watch(CountAtom().changes(of: \.description))
var description // : String
var body: some View {
Text(description)
}
}
Description | |
---|---|
Summary | Prevents the atom from updating its child views or atoms when its new value is the same as its old value. |
Output | T: Equatable |
Compatible | All atom types that produce Equatable compliant value. |
Use Case | Performance optimization |
📖 Example
struct CountAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
12345
}
}
struct CountDisplayView: View {
@Watch(CountAtom().changes)
var count // : Int
var body: some View {
Text(count.description)
}
}
Description | |
---|---|
Summary | Animates the view watching the atom when the value updates. |
Output | T |
Compatible | All atom types. |
Use Case | Apply animation to a view |
📖 Example
struct TextAtom: ValueAtom, Hashable {
func value(context: Context) -> String {
""
}
}
struct ExampleView: View {
@Watch(TextAtom().animation())
var text
var body: some View {
Text(text)
}
}
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 |
📖 Example
struct FetchWeatherAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> Weather {
try await fetchWeather()
}
}
struct WeatherReportView: View {
@Watch(FetchWeatherAtom().phase)
var weatherPhase // : AsyncPhase<Weather, any 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.")
}
}
}
The attributes allow control over how the atoms essentially work, for example, cache control of the state.
Scoped
preserves the atom state in the scope nearest to the ancestor of where it is used and prevents it from being shared out of scope.
📖 Example
In the example case below, each SearchPane
uses the SearchQueryAtom
state isolated for each scope.
struct SearchQueryAtom: StateAtom, Scoped, Hashable {
func defaultValue(context: Context) -> String {
""
}
}
VStack {
AtomScope {
SearchPane()
}
AtomScope {
SearchPane()
}
}
KeepAlive
allows the atom to preserve its data even if it's no longer watched from anywhere.
📖 Example
In the example case below, once master data is obtained from the server, it can be cached in memory until the app process terminates.
struct FetchMasterDataAtom: ThrowingTaskAtom, KeepAlive, Hashable {
func value(context: Context) async throws -> MasterData {
try await fetchMasterData()
}
}
Refreshable
allows you to implement a custom refreshable behavior to an atom.
📖 Example
It adds custom refresh behavior to ValueAtom
which is inherently unable to refresh.
It's useful when need to have arbitrary refresh behavior or implementing refresh when value depends on private atom.
In this example, FetchMoviesPhaseAtom
transparently exposes the value of FetchMoviesTaskAtom
as AsyncPhase
so that the error can be handled easily inside the atom, and Refreshable
gives refreshing behavior to FetchMoviesPhaseAtom
itself.
private struct FetchMoviesTaskAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> [Movies] {
try await fetchMovies()
}
}
struct FetchMoviesPhaseAtom: ValueAtom, Refreshable, Hashable {
func value(context: Context) -> AsyncPhase<[Movies], any Error> {
context.watch(FetchMoviesTaskAtom().phase)
}
func refresh(context: CurrentContext) async -> AsyncPhase<[Movies], any Error> {
await context.refresh(FetchMoviesTaskAtom().phase)
}
func effect(context: CurrentContext) -> some AtomEffect {
UpdateEffect {
if case .failure = context.read(self) {
print("Failed to fetch movies.")
}
}
}
}
Resettable
allows you to implement a custom reset behavior to an atom.
📖 Example
It adds custom reset behavior to an atom that will be executed upon atom reset.
It's useful when need to have arbitrary reset behavior or implementing reset when value depends on private atom.
In following example, RandomIntAtom
generates a random value using generated from private RandomNumberGeneratorAtom
, and Resettable
gives ability to replace exposed reset with RandomNumberGeneratorAtom
reset.
struct RandomIntAtom: ValueAtom, Resettable, Hashable {
func value(context: Context) -> Int {
var generator = context.watch(RandomNumberGeneratorAtom())
return .random(in: 0..<100, using: &generator)
}
func reset(context: CurrentContext) {
context.reset(RandomNumberGeneratorAtom())
}
}
private struct RandomNumberGeneratorAtom: ValueAtom, Hashable {
func value(context: Context) -> CustomRandomNumberGenerator {
CustomRandomNumberGenerator()
}
}
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.
Description | |
---|---|
Summary | This property wrapper is similar to @State or @Environment , but is always read-only. It recomputes the view with value changes. |
Compatible | All atom types |
📖 Example
struct UserNameAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> String {
"John"
}
}
struct UserNameDisplayView: View {
@Watch(UserNameAtom())
var name
var body: some View {
Text("User name: \(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. |
Compatible | StateAtom |
📖 Example
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 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. |
Compatible | ObservableObjectAtom |
📖 Example
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)
}
}
}
}
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 to reset an asynchronous atom value and wait for its completion.
await context.refresh(FetchMoviesAtom())
reset(_:)
operator 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.
📖 Example
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()
}
}
}
Context is a structure for using and interacting with atom values from views or other atoms.
API | Use |
---|---|
watch(_:) | Gets an atom value and starts watching its update. |
read(_:) | Gets an atom value but does not watch its update. |
set(_:for:) | Sets a new value to the atom. |
modify(_:body:) | Modifies the cached atom value. |
subscript[] | Read-write access for applying mutating methods. |
refresh(_:) | Produce a new value of the atom after waiting until asynchronous operation is complete. |
reset(_:) | Reset an atom to the default value or a first output. |
Contexts are provided in the following types depending on the environment where they are provided. In addition to the common APIs described above, each context type may have its unique functionalities.
A context available through the @ViewContext
property wrapper when using atoms from a view.
API | Use |
---|---|
binding(_:) | Gets a binding to the atom state. |
snapshot() | For debugging, takes a snapshot that captures specific set of values of atoms. |
📖 Example
struct SearchQueryAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> String {
""
}
}
struct FetchBooksAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> [Book] {
let query = context.watch(SearchQueryAtom())
return try await fetchBooks(query: query)
}
}
struct BooksView: View {
@ViewContext
var context: AtomViewContext
var body: some View {
// watch
let booksTask = context.watch(FetchBooksAtom()) // Task<[Book], any Error>
// binding
let searchQuery = context.binding(SearchQueryAtom()) // Binding<String>
List {
Suspense(booksTask) { books in
ForEach(books, id: \.isbn) { book in
Text("\(book.title): \(book.isbn)")
}
}
}
.searchable(text: searchQuery)
.refreshable {
// refresh
await context.refresh(FetchBooksAtom())
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
HStack {
Button("Reset") {
// reset
context.reset(SearchQueryAtom())
}
Button("All") {
// set
context.set("All", for: SearchQueryAtom())
}
Button("Space") {
// subscript
context[SearchQueryAtom()].append(" ")
}
Button("Print") {
// read
let query = context.read(SearchQueryAtom())
print(query)
}
Button("Snapshot") {
// snapshot
let snapshot = context.snapshot()
print(snapshot)
}
}
}
}
}
}
A context passed as a parameter to the primary function of each atom type.
📖 Example
final class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { ... }
struct LocationManagerDelegateAtom: ValueAtom, Hashable {
func value(context: Context) -> LocationManagerDelegate {
LocationManagerDelegate()
}
}
struct LocationManagerAtom: ValueAtom, Hashable {
func value(context: Context) -> any LocationManagerProtocol {
let delegate = context.watch(LocationManagerDelegateAtom())
let manager = CLLocationManager()
manager.delegate = delegate
return manager
}
}
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.
API | Use |
---|---|
lookup(_:) | Gets an atom value without creating a cache. |
unwatch(_:) | Simulates a scenario in which the atom is no longer watched. |
override(_:with:) | Overwrites the output of a specific atom or all atoms of the given type with the fixed value. |
waitForUpdate(timeout:) | Waits until any of the atoms watched through this context have been updated. |
wait(for:timeout:until:) | Waits for the given atom until it will be a certain state. |
onUpdate | Sets a closure that notifies there has been an update to one of the atoms. |
📖 Example
protocol APIClientProtocol {
func fetchMusics() async throws -> [Music]
}
struct APIClient: APIClientProtocol { ... }
struct MockAPIClient: APIClientProtocol { ... }
struct APIClientAtom: ValueAtom, Hashable {
func value(context: Context) -> any APIClientProtocol {
APIClient()
}
}
struct FetchMusicsAtom: ThrowingTaskAtom, Hashable {
func value(context: Context) async throws -> [Music] {
let api = context.watch(APIClientAtom())
return try await api.fetchMusics()
}
}
@MainActor
class FetchMusicsTests: XCTestCase {
func testFetchMusicsAtom() async throws {
let context = AtomTestContext()
context.override(APIClientAtom()) { _ in
MockAPIClient()
}
let musics = try await context.watch(FetchMusicsAtom()).value
XCTAssertTrue(musics.isEmpty)
}
}
AtomScope
allows you to monitor changes or override atoms used in descendant views. Unlike AtomRoot
, they affect only those in scope.
See the Atom Override and Debugging sections for specific uses.
AtomScope {
CounterView()
}
.scopedObserve { snapshot in
if let count = snapshot.lookup(CounterAtom()) {
print(count)
}
}
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, any Error>
var body: some View {
Suspense(newsTask) { news in
Text(news.content)
} suspending: {
ProgressView()
} catch: { error in
Text(error.localizedDescription)
}
}
}
This library is designed with the shared state as a single source of truth first principle, but also the state can be scoped depending on the intended use.
Scoped atoms preserves the atom state in the AtomScope nearest to the ancestor of where it is used and prevents it from being shared out of scope. Scoped
is the attribute for that feature.
struct TextInputAtom: StateAtom, Scoped, Hashable {
func defaultValue(context: Context) -> String {
""
}
}
struct TextInputView: View {
@Watch(TextInputAtom())
...
}
VStack {
// The following two TextInputView don't share TextInputAtom state.
AtomScope {
TextInputView()
}
AtomScope {
TextInputView()
}
}
When multiple AtomScope
s are nested, and you want to store and share an atom state in the particular scope, it is able to define a scope ID which is to find a matching scope.
struct TextScopeID: Hashable {}
struct TextInputAtom: StateAtom, Scoped, Hashable {
var scopeID: TextScopeID {
TextScopeID()
}
func defaultValue(context: Context) -> String {
""
}
}
AtomScope(id: TextScopeID()) {
TextInputView()
AtomScope {
// Shares TextInputAtom state with the TextInputView placed in the parent scope.
TextInputView()
}
}
This is also useful when multiple identical screens are stacked and each screen needs isolated states such as user inputs.
Note that other atoms that depend on scoped atoms will be in a shared state and must be given Scoped
attribute as well in order to scope them as well.
Atom effects are an API for managing side effects that are synchronized with the atom's lifecycle. They are widely applicable for variety of usage such as state synchronization, state persistence, logging, and etc, by observing and reacting to state changes.
You can create custom effects that conform to the AtomEffect
protocol, but there are several predefined effects.
API | Use |
---|---|
InitializeEffect | Performs an arbitrary action when the atom is initialized. |
UpdateEffect | Performs an arbitrary action when the atom is updated. |
ReleaseEffect | Performs an arbitrary action when the atom is released. |
MergedEffect | Merges multiple atom effects into one. |
Atom effects are attached to atoms via the Atom.effect(context:)
function.
struct CounterAtom: StateAtom, Hashable {
func defaultValue(context: Context) -> Int {
UserDefaults.standard.integer(forKey: "persistence_key")
}
func effect(context: CurrentContext) -> some AtomEffect {
UpdateEffect {
UserDefaults.standard.set(context.read(self), forKey: "persistence_key")
}
}
}
Each atom initializes its effect when the atom is initialized, and the effect is retained until the atom is no longer used from anywhere and is released, thus it allows to declare stateful side effects.
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
}
}
You can override atoms in AtomRoot or AtomScope to overwirete the atom states for dependency injection or faking state in particular view, which is useful especially for testing.
Overriding in AtomRoot
return the given value instead of the actual atom value no matter where the overridden atom is used in its descendant views.
// Overrides the CounterAtom value to be `456` in anywhere in the ancestor.
AtomRoot {
RootView()
}
.override(CounterAtom()) { _ in
456
}
On the other hand, overriding with AtomScope
behaves similar to overriding in AtomRoot
, but the atoms used in other scopes nested in the descendants are not overridden.
// Overrides the CounterAtom value to be `456` only for this scope.
AtomScope {
CountDisplay()
// CounterAtom is not overridden in this scope.
AtomScope {
CountDisplay()
}
}
.scopedOverride(CounterAtom()) { _ in
456
}
If you want to inherit the overridden atom from the parent scope, you can explicitly pass @ViewContext
context that has gotten in the parent scope. Then, the new scope completely inherits the parent scope's context.
@ViewContext
var context
var body: some {
// Inherites the parent scope's overrides.
AtomScope(inheriting: context) {
CountDisplay()
}
}
Note that overridden atoms in AtomScope
automatically be scoped, but other atoms that depend on them will be in a shared state and must be given Scoped
attribute (See also: Scoped Atom) in order to avoid it from being shared across out of scope.
See Testing section for details on dependency injection on unit tests.
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, functions that tests 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) -> any 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)
}
}
class FetchBookTests: XCTestCase {
@MainActor
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)
}
}
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 state changes and always continue to receive Snapshots
at that point in time with observe(_:)
modifier of AtomRoot or with scopedObserve(_:)
modifier of AtomScope.
Note that observing in AtomRoot
will receive every state changes that happened in the whole app, but observing in AtomScope
will observe changes of atoms used in the scope.
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.
digraph {
node [shape=box]
"FilterAtom"
"FilterAtom" -> "TodoApp/FilterPicker.swift" [label="line:3"]
"FilterAtom" -> "FilteredTodosAtom"
"TodosAtom"
"TodosAtom" -> "FilteredTodosAtom"
"FilteredTodosAtom"
"FilteredTodosAtom" -> "TodoApp/TodoList.swift" [label="line:5"]
"TodoApp/TodoList.swift" [style=filled]
"TodoApp/FilterPicker.swift" [style=filled]
}
Even in SwiftUI previews, the view must have an AtomRoot
somewhere in the ancestor.
To inject dependencies so that display a static preview, define the dependencies as atoms and override them.
struct NewsList_Preview: PreviewProvider {
static var previews: some View {
AtomRoot {
NewsList()
}
.override(APIClientAtom()) { _ in
StubAPIClient()
}
}
}
See Override Atoms section for more details of dependency injection.
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.
📖 Example
struct TextAtom: StateAtom, Hashable {
func value(context: Context) -> String {
""
}
}
struct TextCopyView: View {
@ViewContext
var context
var body: some View {
Button("Copy") {
UIPasteboard.general.string = context.read(TextAtom())
}
}
}
Each atom must have a unique key
to be uniquely associated with its value. As described in the Atom 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.
📖 Example
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)")
}
}
}
}
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.
📖 Example
struct MessageLoaderAtom: ObservableObjectAtom, Hashable {
func object(context: Context) -> MessageLoader {
MessageLoader(context: context)
}
}
@MainActor
class MessageLoader: ObservableObject {
let context: AtomContext
@Published
var phase = AsyncPhase<[Message], any Error>.suspending
init(context: AtomContext) {
self.context = context
}
func load() async {
let api = context.read(APIClientAtom())
phase = await AsyncPhase {
try await api.fetchMessages(offset: 0)
}
}
func loadNext() async {
guard let messages = phase.value else {
return
}
let api = context.read(APIClientAtom())
let nextPhase = await AsyncPhase {
try await api.fetchMessages(offset: messages.count)
}
phase = nextPhase.map { messages + $0 }
}
}
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.
💡 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(inheriting: context) {
MailView()
}
}
}
}
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:
💡 Click to expand workaround
@ViewContext
var context
...
.refreshable { [context] in
await context.refresh(FetchDataAtom())
}
@State
var isShowingSearchScreen = false
...
.onSubmit { [$isShowingSearchScreen] in
$isShowingSearchScreen.wrappedValue = true
}
Any type of contribution is welcome! e.g.
- Give it star ⭐ & fork this repository.
- Report bugs with reproducible steps.
- Propose new features.
- Add more documentations.
- Provide repos of sample apps using this library.
- Become a maintainer after making multiple contributions.
- Become a sponsor.