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: 2Add pod 'Effective', '~> 0.0.1' to your Podfile and run pod install.
Then import Effective.