A super lightweight library for "redux-like" development in scala.js
While there are existing state management solutions for scala.js (Diode, redux) my requirements for a project were too simple to throw a complex framework at it
- simplicity, ease of use and ease of understanding (no magic imports, etc.)
- scala.js idiomatic
- leverage FP but be pragmatic (don't obsess with purity, effect tracking, ...)
- minimal/clean API (make common things easy and specialized things possible)
TODO artifact coordinates
Get started by instantiating a Store[S, M].
Store is a (stateful) central processing unit typed to hold a state S
and accept messages of type M.
val store = Store(MyState()) // : Store[MyState, Any]creates a new "generic" store. It is capable of handling any type of message. It can be useful to narrow acceptable messsage down to a type.
Use
val store = new Store[MyState, MyMessage](MyState())or
val store = Store.accepting[MyMessage].init(MyState())to create a Store[MyState, MyMessage].
While the state can be any class, it is recommended to be a case class with sensible defaults. e.g.
case class MyState(alerts: List[String] = Nil, ...)Note that in order for changes to be detected
equals is called on the state or parts thereof.
Which is why it is recommended to use pure values/data
We now can send messages to the store of the acceptable type via dispatch
or just using the apply method.
Once instantiated, a Store is a function M => Unit from usage side.
store.dispatch(SomeMessage("param"))
// or
store(SomeMessage("param"))Behaviour is installed in the store by adding reducers. Have a look at the internal definition of a store's reducer:
type Reducer = (S, M) => S Add behaviour to the store by defining a new reducer:
store.addReducer { (s, m) =>
... // use m and return updated s
}The Reducer will match on the message to decide on how to update the state
as well as provide a default (the untouched state) for all unhandled cases.
Since this is a common pattern it is syntactially improved by using a curried reducer.
type ReducerCurried = S => M => S This is the curried form of a pure function which takes a state and a message and returns the resulting or "next" state.
It can be installed as
S => PartialFunction[M, S] which gives us the possibility
to non-exhaustively list all messages that should be handled.
store.reduce(state => {
case ChangeName(newName) => state.copy(nameState = ...)
case OtherMessage => ...
// no default case necessary since there is no exhaustiveness check
})It can be read as: "given a state use it to handle all listed messages"
After a message is received by the store all Reducers
are invoked in the order of registration. That implies that later
registered state transitions of some message M
can react to previously installed behaviour (state changes).
The reduction step is performed "transactionally"
i.e. if an error occurs the state is left
unchanged (or rolled back if you will).
The state after each invocation
is fed into the next Reducer resulting in a foldLeft semantic.
The current state which is the last sucessfully reduced state is
available via store.state.
Furthermore the downstream of the store can take two forms:
Subscription (targeted state downstream) or
Listener (targeted message downstream).
If the state changes due to reduction
the changed state can be consumed by subscribers.
They take the form S => Unit and can be created as follows:
store.subscribe { state =>
... // use changed state
}Change detection works via deep equals on the state, as mentioned before.
The Listener is a side-effecting function which gives the store
the capability to react to messages.
type Listener = M => UnitSimilar to addReducer installing a listener is done by calling:
store.addListener { m =>
... // use m
}And similar to reduce the listen API handles messages non-exhaustive.
store.listen {
case Handled(param) => ...
}Dispatching a message from within a listener is possible and encouraged (e.g. for messages resulting in ajax calls).
Note that if the current "digest" is not finished (not all listeners are processed) the dispatched message is queued to ensure a clean ordering of messages.
The previous sections introduced concepts and APIs which are useful only in the simplest of use cases.
If state and messages grow in complexity adding behaviour becomes cumbersome, amount of messages that are not handled properly increases and state change detection is always global.
There are APIs to zoom in on state and message types to make the store behave more robust and sensible while keeping the boilerplate to a minimum
Assume there is a component capable of issuing a Message
having two concrete types (message is a "sum-type"):
// i.e. Message = ChangeName | ConfirmName
sealed trait ComponentMessage
object ComponentMessage {
final case class ChangeName(newName: String) extends ComponentMessage
case object ConfirmName extends ComponentMessage
}
to install a reducer for a particular message type use addMessageReducer
store.addMessageReducer[ChangeName] { (state, message) => // message is guaranteed to be of type "ChangeNamed"
state.copy(nameState = message.newName)
...
})To use the curried function syntax introduced earlier, but
benefit from type safety (i.e. exhaustiveness matching) use reduceMessage.
store.reduceMessage[ComponentMessage](state => {
case ChangeName(newName) => state.copy(nameState = ...)
// warning/error since ConfirmName is not handled
})Listeners can also installed pre-selecting messages to be handled with listenTo.
store.listenTo[ChangeName] { message => // mesage is guaranteed ot be of type ChangeName
console.log(s"name ${message.name} has been entered and processed to be ${store.state.sanitizedName}")
}Note that the reduced state after receiving the message ChangeName
is accessed via store.state in the listener.
Subscriptions without specific change detection have limits in their usefulnes. In general we are only interested in certain changes of the state.
This is why a subscription is actually function S => Unit composed of two functions
S => A(selection)A => Unit(consumption)
Assume we have a state case class State(componentState = ComponentState(), ...)
and we want to focus on the component substate.
To create a subscription we first select a slice of the state
and subscribe to changes:
store.select(_.componentState).subscribe { cs =>
... // use changed componentState cs
}If your downstream consumers are functions or side effecting methods, subscriptions become
store.select(_.componentState).subscribe(myComponent.update)If a message is received by the store, after the state is reduced the following mechanics are invoked:
- compute the slice of each subscription (selection)
- compare each slice with the slice of the previous state
- if changed, invoke the downstream function (consumption) with the slice as parameter
select thus slices the state for readonly functionality with a "getter" function
To "upgrade" the slice for adding behaviour use modifying which takes a "setter" function
val sliced = store
.select(_.componentState)
.modifying((s, cs) => s.copy(componentState = cs))
// results in something like `Store[ComponentState, M]"Getter" and "setter" can also be combined with the lens method
val lensed = store.lens(_.componentState)((s, a) => s.copy(componentState = a))
// equivalent to "sliced"This state is now able to add reducers and subscribe to changes based on the slice of the state
lensed.reduce(compState => {
case SomeMessage => compState.doFoo() // returns a ComponentState
...
})lensed.subscribe(myComponent.update)Store offers two APIs for error handling: addErrorListener and addErrorHandler
Any exceptions occuring in the reduction steps or at downstream consumption
can be either consumed for e.g. logging (addErrorListener)
or used to update the state in a certain way e.g. alerts (addErrorHandler)
The functions dealing with errors are assumed to be fail safe - any exceptions will not be handled further.
// dummy "components" to test output
val counterComponentBuffer = ListBuffer.empty[Int]
val alertBuffer = ListBuffer.empty[String]
// "complex" message
sealed trait CounterMessage
object CounterMessage {
case object Increment extends CounterMessage
case object SetToDefaultValue extends CounterMessage
}
case object CheckCounter
// event not dispatched by components but by external sources
case object SevenWasConfirmedEvent
// the test state
case class AppState(component: CounterState, alertMessage: String)
case class CounterState(value: Int) {
def increment(): CounterState = copy(value = value + 1)
def setTo(int: Int): CounterState = CounterState(int)
}
// kick off
val init = AppState(component = CounterState(0), alertMessage = "")
val store = Store(init)
store
.addListener(msg => println(s"DEBUG: msg received: $msg"))
.addErrorHandler { (ex, state, msg) =>
println(s"ERROR: error ${ex.getMessage} when handling $msg")
state.copy(alertMessage = "An error occured")
}
.select(_.component) // focus into counter component
.subscribe(updatedCounterState =>
counterComponentBuffer prepend updatedCounterState.value
)
.modifying((state, counterState) => state.copy(component = counterState))
.reduceMessage[CounterMessage](counterState => {
case CounterMessage.Increment => counterState.increment()
case CounterMessage.SetToDefaultValue => counterState.setTo(7)
})
.listen {
case CheckCounter =>
if (store.state.component.value == 7) {
// async call after which on success:
store.dispatch(SevenWasConfirmedEvent)
}
}
.delegate // back to root store
.lens(_.alertMessage)((state, alert) => state.copy(alertMessage = alert))
.subscribe(newMsg => alertBuffer prepend newMsg)
.addMessageReducer[SevenWasConfirmedEvent.type]((_, _) => "The 7 was confirmed")
// init all components by pushing the state manually
store.push()No assumptions on output formatting is been made here. Use your subscriptions as you wish. It could be HTML outputting or console logging, etc.
To initialize the store "in one go" and have typesafe (exhaustiveness) reduction, use the builder DSL
val store = Store.accepting[MyMessage].reducing(MyState(...))((s, m) => m match {
...
}) // = Store[MyState, MyMessage]TODO describe better
There is no extra magic to support async behaviour.
Use a Listener to catch messages that should result in async calls (eg. XHR)
and feed the result back into the store by dispatching to it in the async callback.
TODO better example via listeners and dispatch?
val parent = Store(Nil) // accepts Any message
val child = Store.accepting[TestMessage].init(Nil)
child.addListener(parent) // parent is now notified by all child messages