A Swift state container library with extensible effects, modelled after re-frame.
- RxSwift for observation of store changes
- Immutable store state
- Isolation of effects within effects handlers, keeping event handlers pure and allowing for effect reuse
- A flexible context and interceptor based execution model that allows for individual actions to be extended (rather than the entire store)
struct AppState {
var todos: [String] = []
}
Note: Make sure to declare state properties as var
rather than let
to make
writing your event handlers less cumbersome
extension AppState: Equatable {}
func == (lhs: AppState, rhs: AppState) -> Bool {
return lhs.todos == rhs.todos
}
Actions in Effective are simple structs tagged with the Action
protocol:
struct AddTodo: Action {
let name: String
}
let store = Store(initialState: AppState())
registerEventState
registers a handler for an action of the given actionClass
of the following shape: (State, Action) -> State
.
store.registerEventState(actionClass: AddTodo.self) { (state, action) in
var s = state
s.todos.append(action.name)
return s
}
Note that since state
is immutable, state
is first copied to s
.
Specific keypaths can be observed from the store using store.observe
:
let todos: Driver<[String]> = store.observe(keyPath: \.todos, comparer: ==)
Note that ==
only needs to be passed here since Array
is currently not Equatable
.
When observing values that are Equatable
, comparer
is not required.
store.dispatch(AddTodo(name: "Dispatch more actions"))
Event handlers should avoid having side-effects, both for ease of testing and for isolation of individual effects.
Event handlers that perform side-effects should be registered using registerEventEffects
rather than registerEventState
and return an EffectMap
(see below) rather than a
new state. By returning descriptions of effects rather than executing them, event
handlers can be kept pure and effects can be easily stubbed out for testing by calling registerEffect
.
enum CounterEffect {
case increment
case decrement
}
An effect handler performs arbitrary side-effects given an arbitrary input (of type Any
):
var actionsAdded = 0
store.registerEffect(key: "counter") { action in
if let action = action as? CounterEffect {
switch action {
case .increment:
actionsAdded += 1
}
}
}
Note that the key used to register the effect handler must match the name of the
effect returned in the EffectMap
below.
registerEventEffects
registers a handler for an action of the given actionClass
of the following shape: (CoeffectMap, Action) -> EffectMap
.
The values for each key are the EffectMap
are passed to the effect handler
for the corresponding key (in this case "counter"
is passed CounterEffect.increment
).
struct AddTodoAndIncrement: Action { … }
store.registerEventEffects(actionClass: AddTodoAndIncrement.self) { coeffects, action in
let state = coeffects["state"] as? AppState
var newState = state ?? AppState()
newState.todos.append(action.name)
return [ "counter": CounterEffect.increment,
"state": newState ]
}
The dispatch
effect simply dispatches its argument immediately:
struct PreAddTodo: Action { … }
// Dispatches AddTodo immediately
store.registerEventEffects(actionClass: PreAddTodo.self) { coeffects, action in
return [ "dispatch": AddTodo(name: action.name)]
}
The dispatchAfter
effect dispatches its action after a delay, specified by a DispatchAfter
:
struct AddTodoLater: Action { … }
// Dispatches AddTodo after a delay
store.registerEventEffects(actionClass: AddTodoLater.self) { coeffects, action in
return [ "dispatchAfter": DispatchAfter(delaySeconds: action.delay,
action: AddTodo(name: action.name))]
}
The dispatchMultiple
effect dispatches multiple actions immediately:
struct AddTodos: Action { … }
// Dispatches AddTodo twice
store.registerEventEffects(actionClass: AddTodos.self) { coeffects, action in
let actions = [AddTodo(name: action.name), AddTodo(name: action.name.uppercased())]
return [ "dispatchMultiple": actions]
}
The state
effect replaces the store's state with its argument:
// `state` as effect
store.registerEventEffects(actionClass: AddTodo.self) { coeffects, action in
let state = coeffects["state"] as? AppState
var newState = state ?? AppState()
newState.todos.append(action.name)
return [ "state": newState ]
}
// `state` is implied:
store.registerEventState(actionClass: AddTodo.self) { state, action in
var s = state
s.todos.append(action.name)
return s
}
This is done implicitly when using registerEventState
but needs to be done explicitly when using registerEventEffects
.
Just as effect handlers handle the outputs of event handlers, coeffect handlers handle the inputs to event handlers.
Coeffects injected by registerCoeffect
are available within event handlers registered with registerEventEffects
:
// 1. Register the value for the coeffect (with a value or closure)
store.registerCoeffect(key: "time", value: NSDate())
// 2. Create an interceptor to inject the coeffect
let injectTime = store.injectCoeffect(name: "time")
// 3. Add the interceptor to the event handler
store.registerEventEffects(actionClass: AddTodo.self, interceptors: [injectTime]) { coeffects, action in
let state = coeffects["state"] as? AppState
var newState = state ?? AppState()
// 4. Extract the coeffect in the event handler
let time = coeffects["time"] as? NSDate
let todoName = String(describing: time) + " " + action.name
newState.todos.append(todoName)
return [ "state": newState ]
}
By injecting inputs to event handlers through coeffects, individual coeffects can be replaced
for testing by calling registerCoeffect
with a stub handler implementation.
The enrich
interceptor runs a function to transform the store's state after a given action:
// Deduplicate `todos` after each addition
let dedup = store.enrich(actionClass: AddTodo.self) { state, action in
let newTodos = Array(Set(state.todos))
return AppState(todos: newTodos)
}
store.registerEventState(actionClass: AddTodo.self, interceptors: [dedup]) { state, action in
var s = state
s.todos.append(action.name)
return s
}
The after
interceptor runs a function for side-effects after the event handler:
// Increment a counter after each action
var actionsAdded = 0
let inc = store.after(actionClass: AddTodo.self) { state, action in
actionsAdded += 1
}
store.registerEventState(actionClass: AddTodo.self, interceptors: [inc]) { state, action in
var s = state
s.todos.append(action.name)
return s
}
The debug
interceptor wraps each action, printing actions and their state changes:
store.registerEventState(actionClass: Increment.self, interceptors: [debug]) { s, _ in s + 1 }
store.dispatch(Increment()) // => Handling action: Increment():
// Old State: 1
// New State: 2
Add pod 'Effective', '~> 0.0.1'
to your Podfile and run pod install
.
Then import Effective
.