rescript-replay
v2.0.0
Published
A simple, powerful state manager for Rescript React
Downloads
1
Readme
Rescript Replay
Replay is a powerful and simple state manager for Rescript & React
How it works
Replay is a state store that can be updated by dispatching actions.
Actions in turn notify "beacons" that a state change that they care about is occuring.
When these notifications are emitted to a beacon, any Component that is subscribed to it, gets a fresh new copy of state.
In this way, state gets to the components that need it, without going through those that don't.
Install
npm i rescript-replay
... then add to bsconfig
"bs-dependencies": [
"@rescript/react",
"rescript-replay"
]
Example StateAndActions.res
type state = {
count: int,
greeting: string
}
let initState = {
count: 10,
greeting: "hello world"
}
type action =
| Inc
| ChangeGreeting(string)
| None
let reducer = (state, action, notifyBeacons) =>
({
count: switch action {
| Inc => {
["count"] -> notifyBeacons // this sideEffect notifies beacons that the state is changing
state.count + 1 // return new state.count
}
| _ => state.count
},
greeting: switch action {
| ChangeGreeting(newGreeting) => newGreeting
| _ => state.greeting
}
})
let { state, dispatch, useReplay, hooks }: Replay.interface<'state, 'action>
= Replay.register(initState, reducer)
To Use Replay in a Component
// open the StateAndActions.res from above
open StateAndActions
@react.component
let make = () => {
// Here we `useReplay` to get a new version of the state-store whenever the "count" beacon is notified.
// Note that we are using destructure syntax to assign state.count to count.
// Also note: | Subscribing to multiple beacons is possible.
// | If multiple beacons fire from one action - no problem - the component will only be
// | notified once. :)
// | Here we are labelling the subscription "MyComponent".
let { count } = useReplay(["count"], "MyComponent")
<div>
<h1>{ React.string("My Component") }</h1>
<h2>{ React.string(j`Hey look, it's $count`)}</h2>
<div>
// Here we are dispatching the `Inc` action whenever the button is clicked.
<button onClick={(_) => dispatch(Inc) }>{ React.string("Increment") }</button>
</div>
</div>
}
Replay Lifecycle Hooks
You can use Replay quite happily without needing to interact with Lifecycle Hooks, however hooks provide:
- powerful insights into what is happening,
- and the ability to affect what is happening
Hooks let you debug the entire Replay Lifecycle, which can be useful for seeing how actions affect state, what beacons are being notified, and so on.
Hooks allow you to move side-effects out of the Reducer function, so it can remain a Pure-function.
Hooks allow Effect-only actions that bypass the Reducer altogether.
Hooks allow the suppressing of state and render updates.
Example StateAndActions.res including thorough debugging of the Replay Lifecycle
In this example, we log the whole Replay Lifecycle through the use of the Replay Hooks.
type state = {
count: int,
greeting: string
}
let initState = {
count: 10,
greeting: "hello world"
}
type action =
| Inc
| ChangeGreeting(string)
| None
let reducer = (state, action, notifyBeacons) =>
({
count: switch action {
| Inc => {
["count"] -> notifyBeacons // this sideEffect notifies beacons that the state has changed
state.count + 1
}
| _ => state.count
},
greeting: switch action {
| ChangeGreeting(newGreeting) => newGreeting
| _ => state.greeting
}
})
let { state, dispatch, useReplay, hooks: hookApi }: Replay.interface<'state, 'action>
= Replay.register(initState, reducer)
// The Replay LifeCycle can be hooked into so a developer can easily debug what is happening...
let displayDebug = true
if (displayDebug) {
hooks(event => switch event {
| ActionDispatched(action, _, _, _) => switch action {
| Inc => {
Js.log("Inc was dispatched")
}
| ChangeGreeting(newGreeting) => Js.log(j`ChangeGreeting was dispatched with value "$newGreeting"`)
| None => Js.log("None was dispatched")
}
| ComponentIsSubscribingToBeacons(cKey, bKeys) => {
Js.log(j`Component "$cKey" is subscribing to beacons:`)
Js.log(bKeys)
}
| ComponentIsUnsubscribingFromBeacons(cKey, bKeys) => {
Js.log(j`Component "$cKey" is unsubscribing from beacons:`)
Js.log(bKeys)
}
| StateBeforeAction(state) => {
Js.log("state before action:")
state -> Js.Json.stringifyAny -> Js.log
}
| SkipReduce(skipReduce) => switch skipReduce {
| true => Js.log("This Action Skips the Reduce Function")
| false => ()
}
| AddBeaconsToNotificationList(beacons) => {
Js.log("Adding the following Beacons to Notification List:")
Js.log(beacons)
}
| SkipRender(skipRender) => switch skipRender {
| true => Js.log("This Action Skips the Render Function. No Beacons will be notified of new state")
| false => ()
}
| StateAfterAction(state) => {
Js.log("state after action:")
state -> Js.Json.stringifyAny -> Js.log
}
| BeaconsToNotify(bKeys) => {
Js.log("Beacons that will be notified:")
Js.log(bKeys)
}
| BeaconToNotifyComponents(bKey, cKeys) => {
Js.log(j`Beacon "$bKey" will notify the following Components:`)
Js.log(cKeys)
}
| ComponentsToNotify(cKeys) => {
Js.log("Consolidated List of Components to be notified and get new state:")
Js.log(cKeys)
}
}
)
}
Using Hooks to handle Side Effects, skip Reduce, and skip Render
You may have noticed that the ActionDispatched Hook passes a number of additional parameters.
ActionDispatched(action, _, _, _) => { ... }
These three 'Hooks', hook back into the Replay API to affect the inner workings.
These params can be summarised as...
ActionDispatched(action, notifyBeacons, skipReduce, skipRender) => { ... }
The notifyBeacons
hook is the same as the hook exposed to the reducer function.
It notifies beacons of state changes.
Using notifyBeacons
here instead of in the reducer means that the reducer can be a pure function.
Notifications to beacons can now be moved to a dedicated function for managing side effects.
let displayDebug = false
// Here we've set up one function to handle Action Side Effects ( and associated logging )
// followed by another function for the rest of the debug logging.
// Debug Logs can be turned off by setting displayDebug to false.
let sideEffects = (event: Replay.hooks<state, action>, displayDebug) => switch (event) {
| ActionDispatched(action, notifyBeacons, _, skipRender) => switch action {
| Inc => {
if (displayDebug) { Js.log("Inc was dispatched") }
["count"] -> notifyBeacons // this sideEffect notifies beacons that the state has changed
skipRender(true)
}
| ChangeGreeting(newGreeting) => Js.log(j`ChangeGreeting was dispatched with value "$newGreeting"`)
| None => Js.log("None was dispatched")
}
| _ => () // Ignore other hooks
}
let debugLogger = (event, displayDebug) => { ... }
hookApi(event => {
sideEffects(event, displayDebug)
debugLogger(event, displayDebug)
})
The ActionDispatched
Hook can also be used to skip the reducer function or render function too...
hooks(event => switch event {
| ActionDispatched(action, _, skipReduce, skipRender) => switch action {
| Inc => {
skipReduce(true)
skipRender(true)
}
...
...
}
)
All in all the Hooks Api allows:
- Effect-only actions that skip the Reducer and Render
- The Reducer to be kept side-effect free
- Multiple Reducer Actions can be played in sequence before triggering a Render Action
- Debugging of the entire Lifecycle
Beacon Placement Strategy
Replay doesn't prescribe where beacons should be placed, but having a strategy around this will simplify your entire application.
One strategy is to think of state as representing the objects and attributes of those objects that exist in an application. Components generally target the objects, rather than the specific attributes, and so setting beacons up to do a similar thing - is a good place to start. If there is a dictionary of the same type of objects, then setting a beacon up to catch changes in the number of objects (adding / removing operations) and then individual beacons for catching the changes on individual objects, is also a good idea. This would allow an outer component manage the list of objects while individual components can manage their own changes.
Testing
Testing state-changes is as simple as testing the reducer with different permutations. Since the reducer is designed to return the same result whenever provided with the same inputs, it is very predictable and testable.
Testing which beacons will be notified can be done by using dispatchAndHook
which allows you to dispatch an action and then capture the relevant information by tapping into hooks.
// This shows how to run a test dispatch and capture the resulting hook events.
// This can be used to test that the action is producing the correct list of beacons to notify.
let capture: ref<array<string>> = ref([])
dispatchAndHook(Inc, event => {
Js.log("event recieved in dispatch and hook")
switch event {
| ActionDispatched(action, notifyBeacons, _, _) => switch action {
| Inc => {
["count"] -> notifyBeacons // this sideEffect notifies beacons that the state has changed
["flam"] -> notifyBeacons
}
| _ => ()
}
| AddBeaconsToNotificationList(beacons) => {
capture:= Js.Array2.concat(capture.contents, beacons)
}
| _ => ()
}
})
let result = capture.contents == ["count", "flam"]
Js.log(j`beacons = ["count", "flam"] :: $result`)
Based on a given set of beacons to notify, and a data-structure representing which beacons connect to which components, it's also possible to identify which components will recieve state changes.
let beacons = Js.Dict.fromArray([
("count", [ "Comp1", "Comp2" ]),
("flam", [ "Comp2" ])
])
// Note that `generateComponentsToNotify` is a pure function
let comps = generateComponentsToNotify(capture.contents, beacons)
let result = comps == [ "Comp1", "Comp2" ]
Js.log(comps)
Js.log(j`components = ["Comp1", "Comp2"] :: $result`)
The Zen of Replay
- While multiple state-stores can be created, less is usually more.
- Often React's useReduce is perfect for state that doesn't extend beyond a single component.
- For state that does extend beyond one component, often one global store is required.
- Use the hooks system to separate effects from simple state change.
- This allows the reducer to be pure and easily tested.
- Effects can all be managed in the same place.
- Have a Beacon Placement Strategy. We provide a starting point for this.