React time travel: the store may not be enough

In Level-up your dependencies we discussed one way to do dependency injection in React applications using the context, introduced the abstraction of flow of logic - which is anything not enclosed in the lifecycle of a component - and quickly went through a couple of examples involving Redux and React Router separately.

We ended the article pointing out a conflict when using Redux and React Router together and promised a detailed solution here in part 2. It turns out it has some surprising implications I did not expect when I first thought of relogic.

As a reminder, here is the problem.

Using Redux and React Router together

Suppose you have a /users route that loads a list of users, cache them in the store and then renders them.

If you are using React Router you may be aware of the debate whether the route should be stored in the Redux store and if so, when to dispatch the action CHANGE_ROUTE to create the new state with the updated route.

Imagine the following scenario: you are in another route where you click on a <Link to="/users" /> that directs you to /users or put even more explicitly, you are in /users and hit refresh.

When do you dispatch the CHANGE_ROUTE action?

Does the user action trigger the route change and then the store must be manually updated? route change -> store change
In this case, the store is not the source of truth any more, and relying on the store to get the current route may lead to some conflicts.

Or instead does the router listen to the store changes and as a consequence updates the current route?
This way the store remains the source of truth but it gets really difficult: any click on the <Link /> must be intercepted to prevent it from directly triggering the route change. And on page refresh well… it’s too late, the route has already changed…

Two main libraries try to fill the gap namely redux-router and react-router-redux. The readme of redux-router summarizes the differences between them pretty well. It explains their respective strengths and weaknesses and to summarize, they both try to solve the problem by somehow making the store aware of the current route or location, but none of them is reliable.
So what are we supposed to do in this situation?

Ignore the route

You should think of your route components as entry points into the app that don't know or care about the parent, and the parent shouldn't know/care about the children.

Says one of the authors of React Router.

And this is a stackoverflow answer from the author of Redux:

I would suggest just using React Router directly and not keeping searchState in Redux. React Router will inject URL parameters into your components, and you can use them in mapStateToProps(state, ownProps) to calculate the final props.

They seem to agree: route change -> store change and do not keep your route in your store. Ok but in practice, what does it mean? what does it imply?

Personally, I was not completely satisfied with part 1 of the article. Sure it explains a simple way to handle complexity at a higher level, and it does not pretend to discard any other opinionated approach. It actually encourages to leverage the advantages of any library that tries to help with a particular dependency. But I felt something was missing.

So I decided I needed a proof of concept, and realized that coordinating time travelling with both Redux and React Router would be a good one, because it is one of the motivations to look for libraries that bridge the gap between those two.

This experiment lead to relogic-redux-devtools a module built around relogic. The demo now uses it.

Time travel

Time travel is the ability to move back and forth among the previous states of an application and view the results in real time. With Redux, given a specific state and a specific action, the next state of the application is always exactly the same. Redux is a predictable state container and it easy to implement time travel with it.

There are several ways to view the results of time travelling. For the purpose of this demo I decided to follow the easiest possible path and downloaded the Redux DevTools Extension for Chrome. One click and I was up and running, really impressive!

Essentially, the extension consists of a store and a monitor displaying its current state. The monitor allows to jump from one action to another and expand the state corresponding to the last selected action. It is possible to programmatically dispatch actions to change this state, and intercept the state change by listening to the user interactions with the monitor.

The idea is that an application can use the DevTools extension and by synchronizing the state of the application with the state of the DevTools it is possible to jump to a previous state in the DevTools and view the corresponding rendering of the application in the UI.

The repository describes how to use the extension and the API is very well documented for a basic usage.

It exposes a __REDUX_DEVTOOLS_EXTENSION__ global to instantiate

const devToolsExtension = window && window.__REDUX_DEVTOOLS_EXTENSION__;
devToolsExtension();

in order to establish a connection to the DevTools

const devTools = devToolsExtension.connect();

which can be initialized with an initial state.

devTools.init({ /* initial state */ });

Then it is possible to manually send a new action and state to be displayed on the monitor

devTools.send(/* action */, /* state */);

and listen to messages dispatched from the monitor, for instance when one jumps to a state in the past.

devTools.subscribe((message) => { 
  if (message.type === 'DISPATCH' && 
      message.payload.type === 'JUMP_TO_ACTION') {

        // get the devTools state
      const devToolsState = JSON.parse(message.state);

      // do something with devTools state
  }
});

This model works well when we restrict the scope of the application to Redux store only, without triggering any side-effect between 2 actions dispatches.
But what if there are several routes with side effects? As we already mentioned, we decided to not store the current route in the application store so how do we keep track of it?

Time travel with Redux and React Router

Suppose page A displays a list of elements A and page B displays a list of elements B.
Let’s go through a possible sequence of actions manually dispatched by a user

man_dispatch = manually dispatched

action                                         state
---------------------------------------------- --------------------------------
1- load page A                                 {}
2- man_dispatch({ type: ADD_A, value: 1 })     { A: [1] }
3- man_dispatch({ type: ADD_A, value: 2 })     { A: [1, 2] }
4- load page B                                 { A: [1, 2] }
5- man_dispatch({ type: ADD_B, value: 1 })     { A: [1, 2], B: [1] }

After all the steps, we are in page B and we can see the element 1 of type B. If we time travel to step 2, we are still in page B and we see nothing, because there is no information in the store about the route and there is no element B at this step. There is one A but we cannot see it because we are on page B. Instead, we should be in page A and see element 1 of type A.

This is one of the reasons why people want the history in the store when doing time traveling.

The store is the source of truth… but which truth?

You can compare time travelling across the Redux store as moving a cursor in a one dimension environment, with a time axis and several steps representing the sequence of state across time.
In the previous example we had 4 steps related to the store.

state: {}
state: { A: [1] }
state: { A: [1, 2] }
state: { A: [1, 2], B: [1] }

            {}              {A:[1]}         {A:[1,2]}       {A:[1,2],B:[1]}
            |               |               |               |       
------------------------------------------------------------------------------>
                                                                          time

But we also need to track the route change, which can be represented by 2 more steps. So how about keeping the previous steps and adding another dimension?

location: { page: A }    
location: { page: A }    state: {}
location: { page: A }    state: { A: [1] }
location: { page: A }    state: { A: [1, 2] }
location: { page: B }    state: { A: [1, 2] }
location: { page: B }    state: { A: [1, 2], B: [1] }

The key here is that there are 2 stores: one for the application and one for the DevTools monitor. And the trick is to keep the application state free from the current route, but synchronize the DevTools state with both application state changes and location changes.

  • Any action dispatched to the application store is also dispatched to the DevTools store.
  • Any route change in the application dispatches a route change in the DevTools store.

And in turn

  • Any action dispatched to the DevTools store is then split: the state part is dispatched to the application store, the location part changes the current route if needed.

The point is, before assuming the store is the source of truth, we have to define on which basis.
It’s like saying 3 + 7 = 10. Are you sure about that? Add 7 days to Wednesday and you get? Wednesday… or, if you prefer in base 8: 011(3) + 111(7) = 010(2)…

So here is one rule upon which we can define our environment: the store and the location are orthogonal. Changing one does not impact the other.

location: { page: A }    
location: { page: A }    state: {}
location: { page: A }    state: { A: [1] }
location: { page: A }    state: { A: [1, 2] }
location: { page: B }    state: { A: [1, 2] }
location: { page: B }    state: { A: [1, 2], B: [1] }

   {page:A}                                             {page:B}
   |                                                    |
   |                                                    |
   |        {}              {A:[1]}         {A:[1,2]}   |   {A:[1,2],B:[1]}
   |        |               |               |           |   |       
------------------------------------------------------------------------------>
                                                                          time

We just added one dimension to the environment. We still have the time axis and we added a dependencies axis. Now not only can we keep track of the changes of the Redux store or the React Router location, we can also apply the same pattern to any kind of dependency.

We just pushed the problem one level up and the important bit is that all of this is possible if we consider every dependency at the same level. Instead of desperately synchronizing the application store with changes related to other dependencies (and this also applies to using thunks to manage side-effects) we have a much broader impact with very small effort if we ignore the synchronization.

If you are interested in the implementation, feel free to get in touch or have a look at the source code, it is exhaustively documented.
relogic-redux-devtools defines a plugin system extendable to any kind of dependency and provides hooks to modify the default logic of a dependency in order to synchronize its state with the DevTools state (note that I intentionally said state of a dependency…).

On top of that, this module does one more thing. The previous example was the simple case, but it does not reflect what happens with side effects. Suppose when loading page B a bunch of actions are dispatched automatically.

man_dispatch = manually dispatched
auto_dispatch = automatically dispatched

action                                         state
---------------------------------------------- --------------------------------
1- load page A                                 {}
2- man_dispatch({ type: ADD_A, value: 1 })     { A: [1] }
3- man_dispatch({ type: ADD_A, value: 2 })     { A: [1, 2] }
4- load page B                                 { A: [1, 2] }
5- auto_dispatch({ type: ADD_B, value: 1 })    { A: [1, 2], B: [1] }
6- auto_dispatch({ type: ADD_B, value: 2 })    { A: [1, 2], B: [1, 2] }
7- auto_dispatch({ type: ADD_B, value: 3 })    { A: [1, 2], B: [1, 2, 3] }

At the end of the sequence, we are on page B with elements 1, 2, 3 of type B rendered on the screen.
If we click on step 2 in the DevTools extension, now that we use relogic-redux-devtools, the route changes in the UI and we see element 1 of type A, as expected.
But if we click on step 5, page B is loaded, all the actions are automatically dispatched and we end up with elements 1, 2, 3 of type B on the screen.

Great… another useless library…

Flows to the rescue

It turns out, the most interesting part of relogic is that it’s just a closure. And because we have to use setLogic to define some logic, we can add a step into this function.

Remember in part 1 the paragraph about proxying, when I wrote that importing a function in a file and using it in a component makes the function a proxy handler of the functionality it should provide? Well actually, relogic moves all that part to the entry point of the application: the memory injected into <Relogic memory/> is a proxy handler of the application bootstrap.
Really, relogic is just a mechanism to move all the complexity from many points to a single one.
It is easier to control this single entry point, and the cool thing is that we can change setLogic based on the environment we want to target.

This is what relogic-redux-devtools does. It adds an internal flag indicating in which mode the application is running and adds a step to setLogic to override a function passed as flows of logic.

  • in normal mode the function runs normally
  • in frozen mode the function is replaced by an empty function

Then every time the DevTools monitor dispatches an action

  • it first freezes the application
  • then calls whatever comes next in the stack
  • and finally resumes the application

For instance in our previous example if we click on a “go to page B” action in the DevTools extension

  • it first freezes the application
  • then calls history.push('/pageB') which automatically calls some function to dispatch the actions, but the application is frozen so this function is replaced by an empty function and is not executed
  • and finally resumes the application

As a result, if we go back to the end of the previous example, when we time travel to step 5 page B is loaded, no action is automatically dispatched and we get the expected state from DevTools, with element 1 of type B on the screen.

relogic-redux-devtools acts as a middleware and replaces setLogic with a very similar method that first deeply parses the logic passed as argument, then replaces any flow with the behaviour we previously described, and finally set the new overridden logic.

In the previous example, the flow would be a function which dispatches the actions and is called on page B enter. It is the function to override. As a general rule of thumb when using relogic, a flow is a high level function added as method of the logic object injected in the application.

We may imagine this middleware could simply override each function added to the logic, but in order to allow better integration with other middleware I prefer adding a flag to any function that can be considered a flow.

This would be the perfect use case for a decorator.

@flow
function fn() {}

but I hate polyfills and adding unnecessary code to my bundle so we are just going to do.

function fn() {}
flow = true;

a la jQuery…
Feel free to file an issue…

Conclusion

If you regularly use flows you can see the benefits. They describe high order behaviours and are good candidates as special citizens of the application.

It is worth noting that everything described in part 1 and part 2 should be considered an abstraction. The last thing I want is to define another syntax. Sure I am presenting some implementations otherwise I would not be able to demonstrate the results, but the bottom line is there is more in React than the view layer, and it can get difficult very quickly.

There are a few issues to solve when developing real world applications and carefully planning the dependency management is definitely one of them. Many times thinking exclusively about state management is too simplistic and can lead to over-complicated situations.

Flow control is one way to solve the problem. It simply moves the complexity from many points to a single one, easy to test and to control, generally one level above all the dependencies, which makes synchronization much more easy to handle.

MORE BY MARZIO

Level-up your dependencies in React

blog comments powered by Disqus