Syncing React State Across Tabs: Using Broadcast Channel API
Francisco Mendes
Posted on February 18, 2024
Introduction
One of the challenges that I find most complicated to solve is synchronizing the state of a React application between various tabs and windows. If we also consider service workers, it becomes even more challenging.
In today's article we are going to create a hook that will be very similar to what we do with useState
but in reality behind the scenes we will take advantage of the Broadcast Channel API.
Getting Started
We start by running the following command:
npm create vite@latest
The template that was used was React with JavaScript.
Then we can open the App.jsx
file and import the following primitives:
import { useEffect, useState, useRef } from "react";
// ...
The next step is to create an abstraction layer with the creation of drivers, that is, I don't want the hook to be limited only by the use of localStorage. Because in the future we may need to use cookies, IndexedDB, among others.
Which might look similar to this:
import { useEffect, useState, useRef } from "react";
class LocalStorageManager {
_storage;
constructor() {
this._storage = window.localStorage;
}
get(key) {
const value = this._storage.getItem(key);
return typeof value === "string" ? JSON.parse(value) : null;
}
set(key, value) {
this._storage.setItem(key, JSON.stringify(value));
}
}
// ...
Next we can move on to the hook definition, to do this we will create a function with three arguments:
-
key
- is the reference to the data that will be saved -
initialData
- initial data of the hook when it is initially rendered in the component -
driver
- manages side-effects and interactions with external resources such as API's
// ...
function usePersistor(key, initialData, driver) {
// ...
}
In the first step we will take advantage of the useState
hook, initializing it with the initial state received in the arguments. Then we will create a reference to the BroadcastChannel
, in which we will provide the contextual key that was also passed in the arguments.
// ...
function usePersistor(key, initialData, driver) {
const [storedData, _setStoredData] = useState(() => initialData);
const _channel = useRef(new BroadcastChannel(key)).current;
// ...
}
Now moving on to the definition of some functions, we start by creating the internal function _readValue
which will obtain the data from the driver and if nothing is saved we return the initial data.
Another function that we will also need to create is setValue
which will receive data as its only arguments and this will be saved in the local state (storedData
), will be persisted from the driver and we will send a message from of BroadcastChannel
to update the state between other tabs and windows.
// ...
function usePersistor(key, initialData, driver) {
const [storedData, _setStoredData] = useState(() => initialData);
const _channel = useRef(new BroadcastChannel(key)).current;
const _readValue = () => {
const value = driver.get(key);
return value ?? initialData;
};
const setValue = (data) => {
driver.set(key, data);
_setStoredData(data);
_channel.postMessage(data);
};
// ...
}
One point we have to take into account is that the state is currently governed by the initial state provided in the initialData
argument and we also have the possibility of obtaining the data from the driver through the internal function _readValue
.
But we need to update the local state, if we have anything saved, to ensure that we have the updated state as soon as the component is mounted. To do this, we will take advantage of the useEffect
hook as follows:
// ...
function usePersistor(key, initialData, driver) {
// ...
useEffect(() => {
const value = _readValue();
_setStoredData(value);
}, []);
// ...
}
The only thing left to do is address the last point, which is updating the local state taking into account the messages that are sent through the BroadcastChannel
. To do this we will also use a useEffect
which will also only be executed as soon as the component is mounted to register an event listener that will listen to the message
event and will update the state with _setStoredData
.
Last but not least, we return a tuple with two elements: storedData
and setValue
.
// ...
function usePersistor(key, initialData, driver) {
// ...
useEffect(() => {
const value = _readValue();
_setStoredData(value);
}, []);
useEffect(() => {
function _listener({ data }) {
_setStoredData(data);
}
_channel.addEventListener("message", _listener);
return () => {
_channel.removeEventListener("message", _listener);
};
}, []);
return [storedData, setValue];
}
Here are some examples of how to use this hook. We need to instantiate the LocalStorageManager
class while maintaining its reference. And then we can create two states within a component, one to synchronize the state of a counter and the other of an input.
export default function App() {
const driver = useRef(new LocalStorageManager()).current;
const [count, setCount] = usePersistor("counter", 0, driver);
const [name, setName] = usePersistor("name", "", driver);
return (
<section>
<button onClick={() => setCount(count + 1)}>count is {count}</button>
<br />
<br />
<input
type="text"
value={name}
onChange={(evt) => setName(evt.target.value)}
/>
</section>
);
}
Conclusion
I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.
Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.
Posted on February 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.