DEV Community

Cover image for Reactive around reducer
Mike Skoe
Mike Skoe

Posted on

Reactive around reducer

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);
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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 as Store.addTask(contentString) instead of dispatch(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,
        );
    }
Enter fullscreen mode Exit fullscreen mode

So now we can use observables like:

let choice = app->useSync(None, getChoice); // app is an observable; getChoice is a pure state's selector
Enter fullscreen mode Exit fullscreen mode

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)