Sync State Across Multiple Tabs Easily: Build a fun canvas drawing app in React

Sync State Across Multiple Tabs Easily: Build a fun canvas drawing app in React

ยท

7 min read

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 use Yarn 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 package

  • Setting up React Router. Our application will mainly have two routes:

    • The / route where users would be drawing

    • The /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:

  1. 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.

  2. Posting a message to a channel:

    myChannel.postMessage("Hello world!");

The message can be any object which will get serialized internally.

  1. Listening to messages on a channel:
    myChannel.onmessage = (event) => { console.log(event); };

  2. 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 to BroadcastChannel

      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 function

      return [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

https://embiem.github.io/react-canvas-draw/

ย