Sharing state between browser tabs with Redux.
Introduction
Web apps are getting more complex each day, not only getting bigger but with users having the need to use multiple instances of the same application on different tabs in some cases. Building this type of applications is not an easy task due to lack of libraries and documentation out there. Given some libraries are deprecated or don’t have many features implemented leaves you, the developer, to deal with pretty much everything; you can opt to live with these limitations of a third party library or build your own solution which could probably cost you more time and money.
There are but a handful of options where you can pick depending on your preference: LocalStorage, Web Workers, Web Sockets & IndexedDB. We won’t be covering Web Sockets given you need a server to do it and we’re shooting for a client-only (browser) solution.
For instance, if you would like to use the localStorage
, there are no libraries to help you accomplish it (at least not everything), luckily there are a couple of blog posts that might give you an idea but it still is not enough, for that reason this could be the most difficult way to implement cross-tab state synchronization. You also have to keep in mind that there’s no way to update the entire state from Redux, you have to dispatch actions instead, but we’ll get to that in a bit. For this specific case we will try two different approaches, one by using no libraries you’ll be guided through the process you have to take on in order to get cross-tab sync working. The second option is by using a library that will help us store everything in the Storage
, but there’s gonna be a couple of things that have to be implemented.
If you want take a shot at Web Workers and send messages between tabs to keep them synced, then use redux-state-sync but you still have to implement a couple of things. There’s a third option and that is to use IndexedDB, for such case the easy way to do it is with a library that already implements synchronization like redux-pouchdb.
We’re gonna explore these three technologies that you can use and we’ll present pros & cons about each one, but the ultimate decision depends on your own preference and the project’s own nature.
We won’t be covering:
- Support for multiple or different browsers/machines. If you need support for this you can implement Web Sockets, but most of the information listed here is still useful for that case scenario.
- Saving the entire state on the
Store
on the “Zero Libraries” implementation. - Loading the initial state (after refresh/brand new instance) from the
Store
on the “Zero Libraries” Implementation.
Implementations
We will use a Redux example from the Redux repo, a very well known one, the TodoMVC List, which is an already built React/Redux app that’s going to save us a lot of time and, at the same time, will allow us to keep our focus.
The TodoMVC List is an app with a very simple user interaction and UI. We will make subtle changes to implement state synchronization and see state updates reflected in real time with multiple instances of the same app running on different tabs within the same browser. If you need support for multiple instances on different browsers you need to implement sockets but we are not going to cover it.
We will be using the base index.js
from the repo for every implementation, each implementation will have the diff marked.
This is the going to be the result, we’ll achieve the same behavior with each implementation:
Option #1: Local Storage / Session Storage
This could probably be the most difficult way to implement cross tab synchronization because there’s little to no documentation out there to guide you through or help you understand the details you need to keep in mind when syncing the state via localStorage
or sessionStorage
. There’s no silver bullet with this one, we are going to get our hands dirty a little bit in order to fully support synchronization with this approach.
There are two paths to follow: Either use no libraries at all and build your own solution from scratch or use a partial solution which actually takes care of some stuff for you.
One thing to keep in mind is that everything in the localStorage
or sessionStorage
needs to be serialized, which means there are calls to JSON.stringify
and JSON.parse
being done. These two functions are well known for being expensive.
Zero libraries
Using no libraries to get this done is not an easy task, the lack of documentation and the list of features to implement can be overwhelming. In order to communicate between two (or more) tabs via the local/session storage is by storing the dispatched actions and listen to the store
event on the other tab(s). We have to keep in mind that we also have to set the initial state from the `storage` otherwise, the tabs will be out of sync when they’re newly opened or refreshed.
First of all we’re going to create a Redux middleware
to store the actions in the localStorage
and replicate them on the other side. We have to create a file to write the middleware:
middleware/storage.js
It’s important to note something on line 6
, the action is being wrapped into another object, this is to prevent the ping pong effect from happening, since it’s a full duplex channel an action dispatched on one side can be fired on the other side, bounced back to the original source and the process will repeat infinitely, this is why we have the if
statement on line 5
. If you need to understand how middlewares work, the Redux middleware docs are pretty good.
We also have to listen to the storage
event on the other tab. for that we are adding another file, an event listener
.
utils/storage-listener.js
The listener parses back the object stored in the storage
and by injecting the actual store, we can use a closure and use store.dispatch
every time the event is triggered. Now, the middleware needs to be included to the Redux flow and the listener needs to be subscribed, we’ll do both on the index
file.
index.js
Lastly, the entire state needs to be stored as well so that when the app opens up on a new tab, the state is consistent with every other tab. This is fairly simple code so I’ll leave it out of the scope.
Pros:
- Can be maintain and updated by the team. Which means you can build on top of it, if needed, very easily.
Cons:
- Needs to be maintain by the team. If you there’s no budget or time, this could be a really bad choice.
- There’s a lot of code to be written for an initial configuration.
- Processing is very expensive due to the data serialization.
- No documentation available.
- You have to build your own support for third party library structures (i.e. ImmutableJS).
redux-persist
This option is a bit easier than the last one because there’s not much code to write since the redux-persist already takes care of the entire state persistence and handling of actions, we just have to define the initial config and then add a listener to replicate the actions and “rehydrate” the Redux store.
Before we start we have to install redux-persist
:
$ yarn add redux-persist
The library author also created a library called redux-persist-crosstab
that, combined with redux-persist
, gives us everything we want out of the box but is no longer supported, which means the latest version of redux-persist
doesn’t work with redux-persist-crosstab
.
We’ll start by defining the listener:
utils/reduxpersist-listener.js
There are two things we have to do with the state when the event storage
is executed: Initially, we have to take the entire state object that we’re storing by calling getStoredState
from redux-persist, and then we dispatch the REHYDRATE
action in the browser to update Redux’s store, this is done by the library itself.
We’re injecting two dependencies to the function: the first parameter is the actual store in order to store.dispatch
, like the previous example, and the redux-persist
config object to obtain the stored state. An async closure is then returned so that we can subscribe to the event to perform the state extraction and rehydration.
index.js
This might look like a lot of code, but with all this we’re already covered for the initial state being loaded from the storage
, which is one thing less to worry about and it’s mostly all configuration objects.
Pros:
- Initial state loaded from the stored state out of the box.
- Easy to grasp and manage.
Cons:
- Too much configuration.
- No all the functionality is supported from the get-go.
- Needs maintainability from the team.
- Processing is very expensive due to the data serialization.
- You have to build your own support for third party library structures (i.e. ImmutableJS).
- Little (next to none) support from the community.
Option #2: Web Workers
The second option is to use Web Workers, it consists on sending the dispatched action through a full duplex channel to broadcast the action to every listener, then the action is received on the other side and re-executed, then again, be careful with the ping pong effect. You have to be really really careful with what’s being sent on the channel because everything within the object needs to be serializable.
We’re going to use a library for this case, a simple one, called redux-state-sync
, its second version which is the one that implements BroadcastChannel, the first one is implemented over Local Storage
Redux-State-Sync 2.0
This library offers a lot of flexibility. We will be using the most simplest config, but a quick look at the docs will give you an idea of what you can achieve with the library, it can be used with ImmutableJS
out of the box, on top of that you have the store synced on every reload or new tab without any extra configuration or code.
First of all, we have to install the library:
$ yarn add redux-state-sync
Using this library is very simple, so will begin by adding the library configuration, a very simple one (it can be as complex as you want). In fact, we’re gonna use an empty config
, as you can see in the code below. Here’s how our index.js
file will end up looking.
index.js
The code above is very intuitive, with the function names is very easily to understand the flow. As you can see is simpler than the two previous options with the storage
`storage`.
We have to do one more thing to the app work. We have to wrap the entire reducer
object with a function from the library called withReduxStateSync
so that the app can sync the state, because of that we will update the index.js
file inside the reducers
folder. Here’s what it looks like after our changes:
reducers/index.js
Pros:
- Minimal configuration needed to get started.
- Load previous state on reload without extra configuration, out of the box.
- All features needed without writing a single line of code, just configs.
Cons:
- BroadcastChannel is not supported by every browser.
Option #3: IndexedDB
As you saw in the previous example, adding cross-tab sync support is very easy with WebWorkers, however IndexedDB gives us another easy option that we can explore. We’re gonna be using a library that already implements state synchronization with Redux and the state it’s gonna be stored within IndexedDB
too in an organized way. We will be using a PouchDB
approach in this case.
Redux-PouchDB
We need two libraries in this case, we need the official PouchDB
library plus support for Redux with redux-pouchdb
.
$ yarn add pouchdb redux-pouchdb
Just like the previous example with WebWorkers, we are going to wrap the Reducers to be able to sync them. We will use the persistentReducer
function from the redux-pouchdb
library to wrap the rootReducer
:
reducers/index.js
Finally, we are going to initialize the PouchDB database and hook the redux-pouchb
middleware into the Redux flow:
index.js
Pros:
- Minimal configuration needed to get started.
- Load previous state on reload without extra configuration, out of the box.
- All features needed without writing a single line of code, just configs.
- IndexedDB is supported in all Browsers.
Cons:
- PouchDB is not use extensively as WebWorkers and might create a high learning curve for devs in the team.
Conclusion
The three options are very useful, whilst using Web Workers could be the best option in most cases due to the large adoption of the JS community and without adding a bunch of dependencies you can get pretty much every feature you need right away. Using the storage
is definitely the most expensive one because not only the data needs to be serializable, but parsed to/from JS objects too with JSON Parse
and Stringify
which is one of the most expensive functions in JS due to its recursive nature, although you can use fast-json-stringify as an alternate in your own implementation or submit a Pull Request to any persisting library you use (if they don’t use it already) and support Open Source projects at the same time!
One thing to keep in mind when storing data into the Storage
or IndexedDB
is that there’s a maximum space limit for each and it varies between browser vendors and whether your app is loaded from a mobile or desktop browser.