Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

ObservableStore

A simple Elm-like Store for SwiftUI, based on ObservableObject.

ObservableStore helps you craft more reliable apps, by centralizing all of your application state into one place and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used Elm or Redux, you get the gist.

Because Store is an ObservableObject, it can be used anywhere in SwiftUI that ObservableObject would be used.

You can centralize all application state in a single Store, use the Store as an EnvironmentObject, or create multiple @StateObject stores. You can also pass scoped parts of a store down to sub-views as @Bindings, as scoped ViewStores, or as ordinary bare properties of store.state.

Example

A minimal example of Store used to increment a count with a button.

import SwiftUI
import Combine
import ObservableStore
/// Actions
enumAppAction{case increment
}
/// Services like API methods go here
structAppEnvironment{}
/// Conform your model to `ModelProtocol`.
/// A `ModelProtocol` is any `Equatable` that has a static update function
/// like the one below.
structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structAppView:View{@StateObjectvarstore=Store(
state:AppModel(),
environment:AppEnvironment())varbody:someView{VStack{Text("The count is: \(store.state.count)")Button(
action:{
// Send `.increment` action to store,
// updating state.
store.send(.increment)},
label:{Text("Increment")})}}}

State, updates, and actions

A Store is a source of truth for application state. It's an ObservableObject, so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an @ObservedObject, a @StateObject, or @EnvironmentObject.

Store exposes a single @Published property, state, which represents your application state. state can be any type that conforms to ModelProtocol.

state is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of ModelProtocol.

structAppModel:ModelProtocol{varcount=0
/// Update function
staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}

The Update returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).

ModelProtocol inherits from Equatable. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.

Effects

Updates are also able to produce asynchronous effects via Combine publishers. This gives you a deterministic way to schedule sync and async side-effects, like HTTP requests or database calls in response to actions.

Effects are modeled as Combine Publishers, which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:

publictypealiasFx<Action>=AnyPublisher<Action,Never>

You can produce effects by exposing services or methods on Environment that produce Combine publishers.

Another common approach is to make the environment (or some of its services) actors. This has the advantage of getting work off the main thread.

actorEnvironment{
// ...
func authenticate(credentials:Credentials)async->Action{
// ...
}}

You can then wrap actor method calls in publishers. ObservableStore provides a helpful extension for this that allows you to construct a Combine Future from an async closure.

Here's an example of creating an effect using an environment actor and returning it as part of the update:

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):letfx=Future{await environment.authenticate(credentials: credentials)}.eraseToAnyPublisher()returnUpdate(state: state, fx: fx)}}

Store will manage the lifecycle of any publishers returned by an Update; piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.

Animations

You can also drive explicit animations as part of an Update.

Use Update.animation to set an explicit Animation for this state update.

func update(
state:Model,
action:Action,
environment:Environment)->Update<Model>{switch action {
// ...
case.authenticate(let credentials):returnUpdate(state: state).animation(.default)}}

When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.

Getting and setting state in views

There are a few different ways to work with Store in views.

Store.state lets you reference the current state directly within views. It’s read-only, so this is the approach to take if your view just needs to read, and doesn’t need to change state.

Text(store.state.text)

Store.send(_) lets you send actions to the store to change state. You might call send within a button action or event callback, for example.

Button("Set color to red"){
store.send(AppAction.setColor(.red))}

Bindings

StoreProtocol.binding(get:tag:) lets you create a binding that represents some part of a store state. The get closure reads the state into a value, and the tag closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, changing state only through deterministic updates.

TextField("Username"text: store.binding(
get:{ state in state.username },
tag:{ username in.setUsername(username)}))

Bottom line, because Store is just an ordinary ObservableObject and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, @Binding, @ObservedObject, @StateObject and @EnvironmentObject all work as you would expect.

Creating scoped child components

We can also create ViewStores that represent just a scoped part of the root store. You can think of them as being like a binding, but they expose a StoreProtocol interface, instead of a binding interface. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store.

Imagine we have a SWiftUI child view that looks something like this:

enumChildAction{case increment
}structChildModel:ModelProtocol{varcount:Int=0staticfunc update(
state:ChildModel,
action:ChildAction,
environment:Void)->Update<ChildModel>{switch action {case.increment:varmodel= state
model.count = model.count +1returnUpdate(state: model)}}}structChildView:View{varstore:ViewStore<ChildModel>varbody:someView{VStack{Text("Count \(store.state.count)")Button("Increment",
action:{
store.send(ChildAction.increment)})}}}

To integrate this child component with a parent component, we're going to need 3 functions:

  • A function to get a local state from the root state
  • A function to set a local state on a root state
  • A function to tag a local action so it becomes a root action

Together, these functions give us everything we need to map from child domain to a parent domain. Let's define them as static functions, so we have them all in one place.

structAppChildCursor{
/// Get child state from parent
staticfunc get(_ state:ParentModel)->ChildModel{
state.child
}
/// Set child state on parent
staticfunc set(_ state:ParentModel, _ child:ChildModel)->ParentModel{varmodel= state
model.child = child
return model
}
/// Tag child action so it becomes a parent action
staticfunc tag(_ action:ChildAction)->ParentAction{switch action {default:return.child(action)}}}

Ok, now that we have everything we need to map from the parent domain to the child domain, let's integrate the child view with the parent view.

We call the store.viewStore(get:tag:) method to create a scoped ViewStore from our store and pass it the appropriate cursor functions.

structContentView:View{@StateObjectprivatevarstore:Store<AppModel>varbody:someView{ChildView(
store: store.viewStore(
get:AppChildCursor.get,
tag:AppChildCursor.tag
))}}

Note that .viewStore(get:tag:) is an extension of StoreProtocol, so you can call it on Store or ViewStore to create arbitrarily nested components!

Next, we want to integrate the child's update function into the parent update function. Luckily, ModelProtocol synthesizes an update(get:set:tag:state:action:environment) function that automatically maps child state and actions to parent state and actions.

enumAppAction{case child(ChildAction)}structAppModel:ModelProtocol{varchild=ChildModel()staticfunc update(
state:AppModel,
action:AppAction,
environment:AppEnvironment)->Update<AppModel>{
switch {
case .child(let action):returnupdate(
get:AppChildCursor.get,
set:AppChildCursor.set,
tag:AppChildCursor.tag,
state: state,
action: action,
environment:())}}}

And that's it! We have successfully created an isolated child component and integrated it into a parent component. This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.

About

A lightweight Elm-like Store for SwiftUI

Resources

Stars

40 stars

Watchers

4 watching

Forks

Releases

Packages

Contributors

Languages