Hello, in this article I will show you how you can sync state in React across multiple tabs. We will be using the SharedWorker API to achieve this. If you want to skip the tutorial and just use it, I have an npm packagereact-tab-state
The tutorial is written in TypeScript (aside from the worker), but you can just as well use JavaScript for this.
This is what the final result will look like:
So without further ado, let's get started!
Project structure
I will assume that you already know how to set up a React project. You can use CRA or Vite or even NextJS, just make sure that the environment supports shared workers(it probably does)!
For this project we will have 3 main files -
index.tsx
, where our page code will resideuseTabState.ts
, where the code for our tab- synced-state hook will resideworker.js
, where the code for our shared worker will reside
worker.js
First of all let's write the code of our worker.
First let's add some variables
letports=[];// All of our portsletlatest_state={}// The currently synced states.
So first we haveports
which stores all of open ports. There is a single port for each tab. A port is basically a connection between a tab and the shared worker, which allows you to send and receive messages. We need this array so we can send a single message to all ports at once
Next,latest_state
stores the most recent synced state values in an object. Each key in the object will be a unique id for that particular state. This is so we can have multiple states. We will need this variable in order to sync the state of a newly opened tab
Now let's add a function to post a message to all ports
// Post message to all connected portsconstpostMessageAll=(msg,excluded_port=null)=>{ports.forEach(port=>{// Don't post message to the excluded port, if one has been specifiedif(port==excluded_port){return;}port.postMessage(msg);});}
What this does is simply post a provided mesage to all ports in theports
array. You can also optionally provide anexcluded_port
, to which the message won't be sent. This is so that when a tab wants to update state in the rest of the tabs, you don't send a message back to the initiator tab.
Let's write anonconnect
handler. Here we will define message handlers and some initialization logic
onconnect=(e)=>{constport=e.ports[0];ports.push(port);}
In the code above, we have created anonconnect
handler and also made sure that the newly connected port gets been added to ourports
array.
In theonconnect
handler, let's add handlers for receiving messages
port.onmessage=(e)=>{// Sent by a tab to update state in other tabsif(e.data.type=='set_state'){}// Sent by a tab to request the value of current state. Used when initializing the state.if(e.data.type=='get_state'){}}
What the code above does is create a message handler for the connected port. So each time a tab sends a message to the worker, this handler will be invoked. Then in the handler we check the type of message and act accordingly.
Now let's add a handler for each message
if(e.data.type=='set_state'){latest_state[e.data.id]=e.data.state;postMessageAll({type:'set_state',id:e.data.id,state:latest_state[e.data.id]},port);}
This handler will get called when a tab sends aset_state
message, a.k.a when state is updated in one of the tabs. When this happens, we update ourlatest_state
with the state that the tab has sent us for the key with a matching ID, as well as send the new state to all the other tabs. We exclude the tab that initially sent the message because it already has the new state.
Next let's add ourget_state
handler. It will be called when a tab with our page first opens, to retrieve the up-to-date state value.
if(e.data.type=='get'){port.postMessageAll({type:'set_state',id:e.data.id,state:latest_state[e.data.id]});}
What we do here is simply send aset_state
message to the tab that requested it, with our up-to-date state.
And that's it for the worker! Pretty simple, right? We're about halfway done.
Now let's write ouruseTabState
hook.
useTabState.ts
Our useTabState will be used the same way as a regularuseState
- you call the hook and it will return an array with the state and asetState()
function
Let's start writing our function:
exportdefaultfunctionuseTabState<T>(initial_state:T,id:string):[T,(new_state:T)=>void]{const[localState,setLocalState]=useState<T>(null);}
So far we have made a hook that accepts 2 arguments -initial_state
for the initial state andid
for a unique ID to distinguish between multiple tab states.
Then we create a state calledlocalState
and set its initial value tonull
You may have noticed that we're using generics here. If you're using TypeScript but don't know how to use generics or don't want to use them, you can remove the<T>
and change any occurence ofT
toany
. If you're using plain JavaScript then you can remove the<T>
and omit the types completely.
Next up let's create a function below thelocalState
that updates the state across all tabs
constsetTabState=(new_state:T)=>{setLocalState(new_state);worker.port.postMessage({type:'set_state',id:id,state:new_state});}
What this function does is set its localstate as well as sends aset_state
message to the worker, resulting in state being synced across all tabs. This is the function that our hook will return.
We're almost done! Now all we have to do is create a listener for when the shared worker sends a message to the tab.
Create auseEffect
and add the following code to it:
useEffect(()=>{worker.port.addEventListener('message',e=>{if(!e.data?.type){return;}switch(e.data.type){case'set_state':{if(e.data.id==id){if(e.data.state){setLocalState(e.data.state);}else{setLocalState(initial_state);}}}}})worker.port.start();// This is important, otherwise the messages won't be received.worker.port.postMessage({type:'get_state',id:id});},[]);
As you can see in the code above, we add a message event listener and update our local state with the up-to-date state when aset_state
message has been received. Also we send aget_state
message upon mount to get the up-to-date state. If the up-to-date state is null, then we use theinitial_state
value.
Finally, let's return ourlocalState
as well as thesetTabState
function:
return[localState,setTabState];
And we're done! Now all that is left is to test our hook!
index.tsx
importuseTabStatefrom'./useTabState';exportdefaultfunctionTabStateExample(){const[counter,setCounter]=useTabState<number>(0,'counter');return(<div><buttononClick={()=>setCounter(counter+1)}>{counter}</button></div>);}
In the code above, we simply create a button that shows a number, and increments this number each time it is clicked. Try testing this page with multiple tabs!
Conclusion
In this article we explored a simple way how one can add a tab-synced state behavior to their app using the SharedWorker API. I hope you learned something new and that you will find a use for this pattern. If you don't want to go through the hassle of learning and implementing this yourself (though it's quite simple), you can use my NPM packagereact-tab-state
If you have any questions or feedback, feel free to leave it in the comments or email me atjorensmerenjanu@gmail.com
Good luck in your dev journey and your projects!
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse