A tiny state management library for Swift.
Substate is a Redux-style state management library consisting of actions, models, and a store. The goal is to leverage Swift’s type system to provide the most ergonomic possible version of the pattern.
Access your Substate models from SwiftUI views, and dispatch actions to update them. Bootstrap your app from SwiftUI, and inject models into previews.
Extend Substate with logging, async tasks, time control, persistent models, action mapping logic, and a standalone debugging app. Connect your own services to Substate.
import Substate
Describe your model using a simple value type. Add some Action
s that will trigger model updates.
struct Counter: Model {
var value = 0
struct Increment: Action {}
struct Decrement: Action {}
mutating func update(action: Action) {
switch action {
case is Increment: value += 1
case is Decrement: value -= 1
default: ()
}
}
}
Then, conform to Model
by adding an update(action:)
method and define how the model should change when actions are received.
Add other models alongside plain values to compose a state tree for your program.
struct Counter: Model {
var value = 0
var subCounter = SubCounter()
mutating func update(action: Action) { ... }
}
Nested models are automatically detected and updated using their own update(action:)
methods.
struct SubCounter: Model {
var value = 0
mutating func update(action: Action) { ... }
}
Reuse models in different places by making them generic with respect to their containers.
struct Tracker<Screen>: Model { ... }
struct NewsScreen: Model {
var tracker = Tracker<NewsScreen>()
}
struct ProductsScreen: Model {
var tracker = Tracker<ProductsScreen>()
}
import SubstateUI
Add @Model
properties to access models from your views.
struct CounterView: View {
@Model var counter: Counter
@Model var subCounter: SubCounter
var body: some View {
Text("Count: \(counter.value)")
Text("Subcount: \(subCounter.value)")
}
}
Use an @Update
property to trigger model updates.
struct CounterView: View {
@Update var update
var body: some View {
Button("Increment", action: update(Counter.Increment()))
Button("Decrement", action: update(Counter.Decrement()))
}
}
Extend your model with data for use in different previews.
extension Counter {
static let zero = Counter(value: 0)
static let random = Counter(value: .random(in: 1...100))
}
Pass your predefined models in to the model
view modifier.
struct CounterViewPreviews: PreviewProvider {
static var previews: some View {
CounterView().model(Counter.zero)
CounterView().model(Counter.random)
}
}
The
model
view modifier is an optional shorthand forenvironmentObject(Store(model:))
.
Bootstrap your program by passing in your root state and a list of middleware to the store
view modifier.
struct CounterApp: App {
var scene: some Scene {
CounterView().store(model: Counter(), middleware: [])
}
}
The
store
view modifier is an optional shorthand forenviromentObject(Store(model:middleware:))
.
For more control or to use Substate separately from SwiftUI, create a store manually.
let store = Store(model: Counter(), middleware: [])
Retrieve models directly from the store by passing the desired type to find
.
store.find(Counter.self) // => Optional<Counter>
store.find(SubCounter.self) // => Optional<SubCounter>
Update your app’s model directly by passing an action to update
.
store.update(Counter.Increment())
import SubstateMiddleware
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
let store = Store(model: Counter(), middleware: [StateLogger(), ActionLogger()])
store.update(Counter.Reset(to: 100))
▿ Substate.State: Counter
- value: 0
▿ Substate.Action: Counter.Reset
- to: 100
▿ Substate.State: Counter
- value: 100
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
struct Increment: Action {}
struct Decrement: Action, LoggedAction {}
let store = Store(model: Counter(), middleware: [ActionLogger(filter: true)]
store.update(Counter.Increment())
store.update(Counter.Decrement())
- Substate.Action: Counter.Decrement
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
struct LoggerDebugView: View {
@Model state: ActionLogger.State
var body: some View {
Text("Logging Actions: \(state.isActive)")
Button("Toggle", action: update(ActionLogger.Toggle()))
}
}
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
// Better name?
EffectHandler(effects: [
.init(for: Counter.Reset.self, capture: true, counterService.fetch),
.init(for: Counter.Reset.self, capture: true) { reset in
counterService
.fetch(number: reset.toValue)
.retry(3)
.map(CounterService.FetchDidComplete.init)
}
.init(state: Counter.self, action: Counter.Increment.self) { counter, increment in
Counter.Reset(toValue: counter.value + 2) // Increment by 2 instead
}
.init(state: Counter.self, action: Counter.Increment.self, capture true) { counter, increment in
print(counter.value) // Just print, return void and swallow action
}
])
// Easily adapt any service type object for use with the effect handler
protocol EffectProvider {
var effects: [Effect] { get } // Provide a list of effects
}
class NumberFetcher {
func fetch(number: Int) -> AnyPublisher<Int, Error>
}
struct Effect<StateType:State> {
// How are we going to get something that can be thrown in an array but can return different things from its handler?
// Custom return type? Was that what they had in Fluxor?
init(for action: Action, capturing state: StateType, passthrough: Bool = true, handler: (StateType?, Action) -> Void) {}
init(for action: Action, capturing state: StateType, passthrough: Bool = true, handler: (StateType?, Action) -> AnyPublisher<where to get the success type?, where to get the error type?>) {}
}
// A bit verbose still? But pretty good.
extension NumberFetcher: EffectProvider {
struct RequestDidComplete: Action {
let value: Int
}
let effects: [Effect] = [
.init(state: Counter.self, action: Counter.Increment.self, capture: true) { counter, increment in
fetch(number: counter.value)
.map(RequestDidComplete.init(value:))
}
]
}
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
ModelPublisher() // PublishedModel
ActionPublisher() // PublishedAction
ModelLogger() // LoggedModel
ActionLogger() // LoggedAction
ModelRecorder() // RecordedModel
ActionRecorder() // RecordedAction
ActionDelayer() // DelayedAction
ActionDebouncer() // DebouncedAction
ActionThrottler() // ThrottledAction
ActionTimer() // TimedAction
ActionExecutor() // ExecutableAction
ActionTrigger() // TriggeringAction, MultipleTriggeringAction // Better name?
ModelSaver(path: URL, throttle: TimeInterval) // SavedModel
Test models by creating a store and sending actions to it. Use the store’s find
method to query values.
func testCounter() throws {
let store = Store(model: Counter())
XCTAssertEqual(store.find(Counter.self)?.value, 0)
store.update(Counter.Increment())
XCTAssertEqual(store.find(Counter.self)?.value, 1)
store.update(SubCounter.Increment())
XCTAssertEqual(store.find(SubCounter.self)?.value, 1)
}
Pass in alternate services to provide behaviour appropriate for your tests. Anything goes — no need to subclass or otherwise hijack your production services unless you want to.
class FixedNumberFetcher: Service {
func handle(action: Action) -> AnyPublisher<Action, Never> {
Just(Numbers.FetchDidSucceed(number: 10)).eraseToAnyPublisher()
}
}
class FailingNumberFetcher: Service {
enum NumberFetcherError { case test }
func handle(action: Action) -> AnyPublisher<Action, Never> {
Just(Numbers.FetchDidFail(error: NumberFetcherError.test)).eraseToAnyPublisher()
}
}
let store = Store(model: Counter(), middleware: [FixedNumberFetcher()])
let store = Store(model: Counter(), middleware: [FailingNumberFetcher()])
Install using Swift Package Manager with this repository’s URL
https://github.com/Substate/Substate.git
TODO.
protocol Service {
func handle(action: Action) async -> Action // Or AsyncSequence?
}
Substate’s novelty is in using Swift’s type system to automatically select child states. The rest is inspired by and lifted from a variety of other wonderful projects. In no particular order:
link |
Stars: 1 |
Last commit: 1 year ago |
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics