Sync State Across Multiple Tabs Easily: Build a fun canvas drawing app in React
When working with an application, we often find users keeping multiple tabs or windows of the same app open. Imagine how delightful it would be if updates made in one tab automatically appeared in another tab or window without needing a refresh or reload. Wouldn't that significantly enhance the user experience?
We will learn how to achieve this behavior in a simple, fun canvas drawing app. Here's a sneak peek ๐ into the app we will be creating:
It is a canvas-powered drawing app with a feature to show a live mirror image of our drawing, allowing us to create beautiful symmetric diagrams.
Let's get started ๐๏ธ
Setup
We will be using
Vite's
React-TS template to initialize the project. You can useYarn
or any package manager of your choice.yarn create vite my-canvas-app --template react-ts
Let's install all the other dependencies that will be needed in our app
yarn add react-router-dom react-canvas-draw lucide-react
-
react-router-dom
: Client-side routing
-react-canvas-draw
: Performant canvas component
-lucide-react
: Icon packageSetting up React Router. Our application will mainly have two routes:
The
/
route where users would be drawingThe
/mirror
route where users would see a mirror image of the drawing
We will initialise React Router with these routes in the root of our application i.e. App.tsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <h1> Canvas Home </h1>, // Placeholder until the component is built
},
{
path: "/mirror",
element: <h1> Canvas Mirror </h1>, // Placeholder until the component is built
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
Adding the components
CanvasHome
This component will render a full-page canvas where users can draw. It will have a toolbar at the top with two buttons: Undo and Clear.
Let's start by adding the canvas component from react-canvas-draw
//pages/CanvasHome/index.tsx
import {useState} from "react";
import CanvasDraw from "react-canvas-draw";
import "./index.css";
function CanvasHome() {
const [canvasData, setCanvasData] = useState("");
const canvasRef = useRef<CanvasDraw>(null);
const handleChange = (canvas: CanvasDraw) => {
const data = canvas.getSaveData();
setCanvasData(data);
}
return (
<CanvasDraw
ref={canvasRef}
onChange={handleChange}
canvasWidth={window.innerWidth}
canvasHeight={window.innerHeight}
lazyRadius={0}
/>
);
}
export default CanvasHome;
The width and height of the canvas are set to match the viewport.
A callback is registered for the
onChange
event, which updates the state with the current canvas data.A
ref
is set, which will be useful later.
Let's add the toolbar with the undo and clear button and a small info section:
//pages/CanvasHome/index.tsx
import {useState} from "react";
import { Eraser, Undo } from "lucide-react";
import CanvasDraw from "react-canvas-draw";
import "./index.css";
function CanvasHome() {
const [canvasData, setCanvasData] = useState("");
const canvasRef = useRef<CanvasDraw>(null);
const handleChange = (canvas: CanvasDraw) => {
const data = canvas.getSaveData();
setCanvasData(data);
}
const handleUndo = () => {
canvasRef.current?.undo();
};
const handleClear = () => {
canvasRef.current?.clear();
setCanvasData(canvasRef.current?.getSaveData() ?? "");
};
return (
<>
<CanvasDraw
ref={canvasRef}
onChange={handleChange}
canvasWidth={window.innerWidth}
canvasHeight={window.innerHeight}
lazyRadius={0}
/>
<div className="top-section">
<div className="toolbar">
<div
className="toolbar__item"
role="button"
onClick={() => handleUndo()}
>
<Undo />
Undo
</div>
<div className="toolbar__divider" />
<div
className="toolbar__item"
role="button"
onClick={() => handleClear()}
>
<Eraser />
Clear
</div>
</div>
<p className="canvas-info">
Start drawing on the canvas and visit the{" "}
<Link target="_blank" to="/mirror">
/mirror
</Link>{" "}
page to see a live mirrored version of your artwork.
</p>
</div>
</>
);
}
export default CanvasHome;
The CSS:
/* pages/CanvasHome/index.css */
.top-section {
position: fixed;
left: 50%;
top: 10px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.canvas-info {
color: hsl(208, 37%, 20%, 50%);
font-size: small;
}
.toolbar {
padding: 12px;
border-radius: 4px;
box-shadow: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
background-color: white;
color: #213547;
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
}
.toolbar__item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 5px;
}
.toolbar__item:hover {
cursor: pointer;
background-color: hsl(221, 57%, 88%);
}
.toolbar__divider {
height: 30px;
width: 1px;
background-color: hsl(208, 37%, 20%, 50%);
}
This is how our component will look at this point, with the ability to draw, undo, and clear.
CanvasMirror
This component is a trimmed-out version of the previous component. It will just load some canvas data in read-only mode (hence the disabled
prop has been passed).
/* pages/CanvasMirror/index.tsx */
import { useEffect, useRef } from "react";
import CanvasDraw from "react-canvas-draw";
import "./index.css";
function CanvasMirror() {
const canvasData = ""; // will be updated later
const canvasRef = useRef<CanvasDraw>(null);
useEffect(() => {
if (!canvasData) return;
canvasRef?.current?.loadSaveData(canvasData, true);
}, [canvasData]);
return (
<>
<CanvasDraw
ref={canvasRef}
canvasWidth={window.innerWidth}
canvasHeight={window.innerHeight}
lazyRadius={0}
brushRadius={0}
disabled
className="canvas-mirror-container"
/>
</>
);
}
export default CanvasMirror;
The component has been inverted on the x-axis to generate a mirror image.
/* pages/CanvasMirror/index.css */
.canvas-mirror-container {
transform: scaleX(-1);
}
Update the react-router setup with these new components:
const router = createBrowserRouter([
{
path: "/",
element: <CanvasHome />,
},
{
path: "/mirror",
element: <CanvasMirror />,
},
]);
Now that the components are ready and functional, let's add the interesting feature: the syncing logic i.e. whenever a user draws something on the home page, a mirror image of the drawing should automatically appear on the mirror page opened in another tab.
Adding the syncing logic using the Broadcast Channel API
As per MDN, The Broadcast Channel API allows basic communication between browsing contexts (that is, windows, tabs, frames, or iframes) and workers on the same origin.
It is a very simple API with four main actions:
Creating or joining a channel:
const myChannel = new BroadcastChannel("channel-name");
If the channel has not been created yet, it will be created first, and then the connection will be established.
Posting a message to a channel:
myChannel.postMessage("Hello world!");
The message can be any object which will get serialized internally.
Listening to messages on a channel:
myChannel.onmessage = (event) => { console.log(event); };
Closing the connection:
myChannel.close();
You can read more about the API here: https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API
Let's write a custom React hook that will use this API internally to keep a state in sync across tabs.
useSyncState hook
This hook will function and can be consumed just like the native useState
hook but with some extended capabilities as mentioned below:
An additional argument
channelName
which will be the name passed toBroadcastChannel
const useSyncState = <T,>( channelName: string, initialState: T ): [T, React.Dispatch<React.SetStateAction<T>>] => { const [state, setState] = useState<T>(initialState); const channel = useRef<BroadcastChannel | null>(null); }
A side effect which will:
Connect to a channel
Update the state whenever the channel receives a new message
Disconnect from the channel as part of the cleanup
useEffect(() => { // create or join a channel channel.current = new BroadcastChannel(channelName); // listen for any messages channel.current.onmessage = (event) => { setState(event.data); }; // cleanup return () => { if (channel.current) { channel.current.close(); } }; }, [channelName]);
Extended
setState
function which will update the state and most importantly post a message to the channel with the updated state.const broadcastState: React.Dispatch<React.SetStateAction<T>> = ( newState: T | ((prevState: T) => T) ) => { setState((prevState) => { let updatedState = newState; if (typeof newState === "function") { updatedState = (newState as (prevState: T) => T)(prevState); } // post a message to the channel with the updated message if (channel.current) { channel.current.postMessage(updatedState); } return updatedState as T; }); };
Finally, it will return the state and the
broadcastState
functionreturn [state, broadcastState];
Putting it all together ๐ช
import { useEffect, useState, useRef } from "react"; const useSyncState = <T,>( channelName: string, initialState: T ): [T, React.Dispatch<React.SetStateAction<T>>] => { const [state, setState] = useState<T>(initialState); const channel = useRef<BroadcastChannel | null>(null); useEffect(() => { // create or join a channel channel.current = new BroadcastChannel(channelName); // listen for any messages channel.current.onmessage = (event) => { setState(event.data); }; // cleanup return () => { if (channel.current) { channel.current.close(); } }; }, [channelName]); const broadcastState: React.Dispatch<React.SetStateAction<T>> = ( newState: T | ((prevState: T) => T) ) => { setState((prevState) => { let updatedState = newState; if (typeof newState === "function") { updatedState = (newState as (prevState: T) => T)(prevState); } if (channel.current) { channel.current.postMessage(updatedState); } return updatedState as T; }); }; return [state, broadcastState]; }; export default useSyncState;
Consuming the hook in the components
CanvasHome
Replace the useState
hook with the new custom hook we built above and pass a channel name as the first argument.
const [, setCanvasData] = useSyncState(MY_CANVAS_BROADACAST_CHANNEL, "");
CanvasMirror
Consume the canvasData
from the useSyncState
hook. Make sure the channel name is the same as above, a good practice is to store it in a constants
file.
const [canvasData] = useSyncState(MY_CANVAS_BROADACAST_CHANNEL, "");
Data flow diagram
Final demo ๐
Source code
References
https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API