Recently, I've been experimenting with an implementation of my library that provides reactivity to ReScript, called ReX. The goal of this project is to implement a low-level reactive library for ReScript and explore a combination of reducers and observables to achieve scalability. Additionally, I want to experiment with OCaml's React-like API and have some fun along the way. You can check an example usage of the library in a React application here
A taste of ReX
Before diving into my thoughts, let me quickly show you an example of how to use the ReX library
let intString = ReX.make(Belt.Int.toString);
let floatString = ReX.make(Belt.Float.toString);
let numbers = ReX.both(intString, floatString, ("0", "0.0"))
->ReX.map(((left, right)) => `${left}:${right}`)
->ReX.debounce(100);
let unsub = numbers->ReX.sub(Js.log);
intString->ReX.call(3);
floatString->ReX.call(2.2);
React Architecture
Sometimes I meditate about Elm-ish (like Redux) vs reactive (like MobX, Jotai, etc). On the one hand, the Elm-ish way provides robust global state management that is easy to test and debug. On the other hand, reactive options let you write a boilerplate code with declarative time-spaced operators. Ultimately I concluded that the best option is a proper combination.
How I see it
[Domain] <- [Reducer] <- [Observable] <- [UI]
When it comes to designing an architecture, I imagine it as a system with multiple layers, each with its own specific purpose.
At the very core, we have the domain of our application, which contains the business logic with no dependencies. Surrounding the domain is a reducer, which is a thin layer that exposes the domain in the form of (action, prevState) => newState
.
Languages that support Algebraic Data Types (ADT) are particularly useful here because they eliminate the need to write any boilerplate code to make actions.
The reducer is then covered with observables, which provide a reactivity part to the architecture. Wrapping our reducers with observables gives us a lot of opportunities to declaratively handle effects like throttling actions, implementing selectors, combining the observables, and more.
As an example, let's imagine a todo application.
Domain
A type of the domain-level modules could look like this:
module Tasks: {
type task = Todo(string) | Done(string, float)
type t = list<task>
let empty: t
let addTask: (t, string) => t
let doneTask: (t, task, float) => t
let filterTasks: (t, task => bool) => t
let isDone: task => bool
let isTodo: task => bool
}
module Filter: {
type t = All | TodoOnly | DoneOnly
let filterTasks: (Tasks.t, t) => Tasks.t
}
Reducer (State)
Next we represents the functionality in the form of actions and a state
module State = {
type action =
| AddTask(string)
| DoneTask(Tasks.task, float)
type state = Tasks.t;
let initialState: state = Tasks.empty;
let reducer = (state: state, action: action): state =>
switch action {
| AddTask(content) => state->Tasks.addTask(content)
| DoneTask(task, timestamp) => state->Tasks.doneTask(task, timestamp)
}
}
Observable (Store)
And now we can use the state in our observables
module Store = {
let { make, id, thunk, either, reduce, sub, both, map, delay } = module(ReX);
// inputs
let addTask = make(str => State.AddTask(str));
let doneTask = make(task => State.DoneTask(task, Js.Date.now()));
let fetchNewTask = make(id)
->thunk((source, dispatch) => {
fetchFrom(source)
->Js.Promise2.then(async response => {
dispatch(State.AddTask(response))
})
->ignore;
});
let taskFilter = make(id);
// global state
let tasks =
addTask
->either(doneTask->delay(500))
->either(fetchNewTask)
->reduce(State.initialState, State.reducer)
// effects (middleware)
let unsubLogFilter = taskFilter->sub(log)
// outputs
let filteredTasks =
tasks
->both(taskFilter, (Tasks.empty, Filter.All))
->map(((tasks, filter)) => Filter.filterTasks(tasks, filter))
}
In terms of Redux, we have:
-
Store.addTask
,Store.fetchNewTask
, etc. as actions. So adding a new task from a React component is as simple asStore.addTask(contentString)
instead ofdispatch(createAddTaskAction(contentString))
-
Store.tasks
as a reducer that contains the most critical logic -
Store.unsubLogFilter
as a middleware -
Store.filteredTasks
as a selector. So that we can expose only the selected slices of states, instead of only the main state, delegating the selection to components
Presentation (View)
In order to be able to use observables in React components, we need to sync them with Component's lifecycle. Here is a usefull hook that takes an observable and a selector and provides a local slice of obsevable's stream
let useSync: (ReX.t<BattleState.action, BattleState.state>, 'a, BattleState.state => 'a) => 'a
=
(t, initial, selector) => {
let snapshot = React.useRef(initial);
React.useSyncExternalStore(
~subscribe = sub => {
let unsub = t->ReX.sub(value => {
snapshot.current = selector(value);
sub();
});
(.) => unsub();
},
~getSnapshot = () => snapshot.current,
);
}
So now we can use observables like:
let choice = app->useSync(None, getChoice); // app is an observable; getChoice is a pure state's selector
The full example of useing the observables together with React can be found here
In conclusion
My thoughts about the layered architecture for reactive programming combined with an immutable global state led me to create the ReX library, which provides reactivity to ReScript. However, if you need to follow a similar approach in production (specifically with TypeScript), I highly recommend trying to connect RxJS to Redux manually and with tools like redux-observable
Top comments (0)