Application State in ReasonReact

The Callback Relay Race - Page 1

TL;DR?

I’ve modified Jared Forsyth’s tutorial on ReasonReact, improving application state management. By sending two props, appState and appSend to all child components, we allow them influence shared state directly, instead of relying on a complex web of callbacks. Sound interesting?

Go on…

Want to implement shared state in ReasonReact applications..?

Probably not, if I’m being practical. The likelyhood of you, the reader, having even heard about ReasonML, let alone ReasonReact, is small. While Reason was featured in theStateOfJs 2017, ~80% of survey respondents said that they’d never heard of it. So if you haven’t, that’s totally OK. Head over to ReasonML’s homepage right now, and give this new (sort of?) language a spin. It’s cool, it’s built by the folks behind React, and is backed by the might of OCaml.

However, if you’re among the ~19.2% who’ve heard about ReasonML, or the ~0.8% who’ve actually tried it and wondered about how shared / global state can work, this tutorial is for you! All of the code I’ll refer to in this article is available on this repo.

But first, some background

Last year, Jared Forsyth published A ReasonReact Tutorial, an excellent starting point for folks interested in building React applications with ReasonML. The tutorial involves building a simple Todo list app, using reducers to manage application state — a feature provided out-of-the-box by ReasonReact. The application structure looks something like this:

App implements reducer(action)
  -> TodoItem onToggle=send(Toggle)
  -> TodoInput onSubmit=send(AddItem(text))

Here the application state is managed in the root component through a reducer, but child components can update application state only through callbacks that are passed down as props from root.

This process of passing callbacks to child components is clunky. In larger applications, where a child component can be distant from root, adding new callbacks can deteriorate into something that looks like a relay race. Here’s an example:

App implementes editCallback(), deleteCallback(), ...
  -> TodoItem editCallback=editCallback deleteCallback=deleteCallback...
    -> TodoInlineEditor editCallback=editCallback deleteCallback=deleteCallback...
      => Use editCallback()
      -> TodoDeleteButton deleteCallback=deleteCallback
        => Use deleteCallback()

The Callback Relay Race - Page 2

Blair Anderson wrote an excellent article titled “You Probably Don’t Need Redux” which explains how, in small-to-medium sized React applications, it’s often enough to pass two props — appState and setAppState to all components, allowing nested components direct access to the shared state, bypassing the need to add and relay individual callbacks.

My implementation of Jared’s tutorial mixes this idea in, and sends two props, appState and appSend, to all components that could influence shared state. For the sake of clarity, I’m only going to point out parts of the code that diverge from Jared’s tutorial.

A reasonable alternative

appState as the name indicates, is the shared state, stored by the root component. appSend is the send method made available to the render method of the root component. We’re passing that alongside appState, to allow child components to trigger the root component’s reducer. The TodoForm component doesn’t need the appState prop because it never reads shared state.

When the TodoItem component uses the prop appSend, it passes an action TodoApp.ToggleItem. To correctly resolve the it as type action, we need to explicity mention the namespace — the shared module TodoApp. Since all components need access to the type definition of state and all possible actions, they must be placed in this separate shared module.

My first attempt at implementing this pattern kept the types state and action in the root component module. However, that just led to a circular dependency issue: App -> child -> App, blocking compilation. Extracting shared code to a different module fixed this.

That’s pretty much it. Notice how there are no callback functions anywhere?

The Callback Relay Race - Page 3

Advantages, and a few caveats

With ReasonReact, we’re using reducers by default, so code that updates shared state is brought into the same module. Child components can now only trigger changes to shared state while the nature of the state change is strictly controlled by the reducer (which should be a pure function). This makes it much easier to control and ensure correctness of shared state.

And given that we’re using a language with a strong, inferred, and most importantly, sound type system, we get all of its benefits as well.

One thing that I’m not totally happy with is that I was forced to open TodoApp (first line in App.re gist above) within the App module. TodoApp defines the state and action types that the App component relies on, and I kept running into syntax errors when I tried to annotate types. All the other modules have manual type annotation when they first refer to either the shared state or an action. Opening TodoApp in the App module isn’t a big issue though, since it’s the root component.

Re-visiting the earlier contrived example with our new pattern, we end up with:

TodoApp implements state and action types, and the reducer.

App uses TodoApp.state, TodoApp.action and TodoApp.reducer
  -> TodoItem appState=App.state appSend=App.send
    -> TodoInlineEditor appState=App.state appSend=App.send
      => props.appSend(TodoApp.Edit(todoId, text))
      -> TodoDeleteButton appSend=App.send
        => props.appSend(TodoApp.Delete(todoId))

I think this is a clearer, and more consise approach than callback-passing. There’s no need to add more batons to the relay race either, when new actions need to be handled.

Conclusion

I’ve been working on a personal project, Turaku - a password manager for teams, with ES6 + React, for the past couple of months, and its increasing complexity meant that the pain of refactoring and adding new features was slowly increasing from mere annoyance to stinging. In my search for a more robust environment, I was drifting towards Typescript when Jasim and Sherin of Protoship.io suggested ReasonML as a (better) alternative.

While I’m still getting used to ReasonML and ReasonReact, my initial impressions have been generally positive. Both the language and the library are evolving rapidly at the moment. For example, while I was working through Jared’s tutorial and trying to adapt it to my preferences, ReasonReact updated and replaced the self.reduce method with the self.send method which is much easier to comprehend and use.

The ReasonML compiler error messages can be pretty confusing at times though. And all too often, it just barks that there’s something wrong with my syntax on a line, informs me that it crashed because of this, and asks me to file a bug on Reason’s repository. ¯\_(ツ)_/¯ Growing pains, I guess.

Over the next couple of months, I’ll attempt a complete re-write of Turaku’s code into ReasonML. I’d been using create-react-app, so the presence of reason-scripts makes it simple to get started. I’m sure I’ll be writing more related to this on a later date. :-)

Credits

Art by Rekha Soman: www.rekhasoman.com