Sharing a state between windows without a server
Tags: webdev, javascript, programming, tutorial
Published: 2023-12-23 14:47:52


Recently, there was a gif trending on social networks displaying an [amazing piece of art made by Bjorn Staal](https://twitter.com/_nonfigurativ_/status/1727322594570027343).

![Bjorn Staal art piece](https://cdn-images-1.medium.com/max/2000/1*vCKb_XLed3eD9y4h-yjdKQ.gif)

I wanted to recreate it, but lacking the 3D skills for the sphere, particles, and physics, I aimed to understand how to make a window react to the position of another window.

Essentially, sharing a state between multiple windows, which I find to be one of the coolest aspects of Bjorn’s project! 
Unable to find a good article or tutorial on the topic, I decided to share my findings with you.
>  Let’s attempt to create a simplified Proof of Concept (POC) based on Bjorn’s work!

![What we’ll try to create ( ofc it’s way less sexy than Bjorn’s work )](https://cdn-images-1.medium.com/max/2000/1*KJHO9DmEDcTISWuCcvDpMQ.gif)

The first thing I did was to list all the ways I know for sharing information between multiple clients:

## Duh: A server

Obviously, having a server (either with polling or websockets) would simplify the problem. However, since Bjorn achieved his result without using a server, this was out of the question.

## Local Storage

Local Storage is essentially a browser key-value store, commonly used for persisting information between browser sessions. While typically used for storing Auth Tokens or Redirect URLs, it can store anything serializable. [You can learn more about it here](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).

I recently discovered some fun APIs of Local Storage, including the *`storage`* event, which fires whenever the Local Storage is changed by another session of the same website.

_Wanna Discover new APIs ?_
{% cta https://notachraf.substack.com %} Subscribe to my Newsletter ( for free ! ) {% endcta %} 


![How the storage event works ( simplified of course )](https://cdn-images-1.medium.com/max/4048/1*otw7fDvd-XFjj9yVBxn5zg.png)

We can leverage this by storing the state of each window in the local storage. Whenever a window changes its state, other windows will be updated via the storage event.

This was my initial idea, and it seems to be the solution Bjorn chose, as he shared his LocalStorage manager code along with an example of using it with threeJs [here](https://github.com/bgstaal/multipleWindow3dScene).

But once I found out that there was code solving this problem, I wanted to see if there was another way… and spoiler alert: Yes, there is!

## Shared Workers

Behind this flashy terminology is a fascinating concept — the concept of WebWorkers.

In simple terms, a worker is essentially a second script running on another thread. While they don’t have access to the DOM as they exist outside the HTML Document, they can still communicate with your main script. 
They are mostly used to offload the main script by handling background jobs, such as pre-fetching information or handling less critical tasks like streaming logs and polling.

![Simplified explanation of the mechanisms of communication between a script and a worker](https://cdn-images-1.medium.com/max/3548/1*izcnWc_p13m8pZy5d49mVw.png)

Shared workers are a special kind of WebWorkers that can communicate with multiple instances of the same script, making them interesting for our use case! Okay, let’s dive right into the code!

![Shared workers can send information to multiple sessions of the same script](https://cdn-images-1.medium.com/max/5428/1*A7ObCM2OjojgfFP57ankyw.png)

### Setting up the worker

As mentioned, workers are a “second script” with their own entry points. Depending on your setup (TypeScript, bundler, development server), you may need to tweak your tsconfig, add directives, or use specific import syntax.

I can’t cover all the possible ways to use a web worker , but you can find the informations on MDN or the internet. 
If needed, I’d happily do a prequel to this article detailing all the ways to set them up!

In my case, I’m using Vite and TypeScript, so I need a `worker.ts` file and installing the `@types/sharedworker` as a dev dependency. We can create our connection in our main script using this syntax:
```typescript
new SharedWorker(new URL("worker.ts", import.meta.url));
```

Basically, we need to:

* Identify each window

* Keep track of all window states

* Alert other windows to redraw once a window changes its state

Our state will be quite simple:
```typescript
type WindowState = {
      screenX: number; // window.screenX
      screenY: number; // window.screenY
      width: number; // window.innerWidth
      height: number; // window.innerHeight
};
```

The most crucial information is, of course, `window.screenX` and `window.screenY` as they tell us where the window is relative to the top-left corner of your monitor.

We’ll have two types of messages:

* Each window, whenever it changes its state, will publish a `windowStateChangedmessage` with its new state.

* The worker will send updates to all other windows to alert them that one of them has changed. The worker will send a `syncmessage` with the state of all windows.

We can start with a plain worker looking a bit like this:
```typescript
    // worker.ts 
    let windows: { windowState: WindowState; id: number; port: MessagePort }[] = [];
    
    onconnect = ({ ports }) => {
      const port = ports[0];
    
      port.onmessage = function (event: MessageEvent<WorkerMessage>) {
        console.log("We'll do something");
      };
    };
```
And our basic connection to the SharedWorker will look something like this. I have some basic functions that will generate an id, and calculate the current window state, also I did some typing on the kind of Message that we can use called WorkerMessage:
```typescript
    // main.ts
    import { WorkerMessage } from "./types";
    import {
      generateId,
      getCurrentWindowState,
    } from "./windowState";
    
    const sharedWorker = new SharedWorker(new URL("worker.ts", import.meta.url));
    let currentWindow = getCurrentWindowState();
    let id = generateId();
```

Once we start the application, we should alert the worker that there is a new window, so we send immediately a message:
```
    // main.ts 
    sharedWorker.port.postMessage({
      action: "windowStateChanged",
      payload: {
        id,
        newWindow: currentWindow,
      },
    } satisfies WorkerMessage);
```
We can listen to this message on our worker side and change the onmessage accordingly. Basically, once the worker receives the windowStateChanged message, either it's a new window, and we append it to the state, or it's an old one that changed. Then we should alert everybody that the state has changed:
```typescript
    // worker.ts
    port.onmessage = function (event: MessageEvent<WorkerMessage>) {
      const msg = event.data;
      switch (msg.action) {
        case "windowStateChanged": {
          const { id, newWindow } = msg.payload;
          const oldWindowIndex = windows.findIndex((w) => w.id === id);
          if (oldWindowIndex !== -1) {
            // old one changed
            windows[oldWindowIndex].windowState = newWindow;
          } else {
            // new window 
            windows.push({ id, windowState: newWindow, port });
          }
          windows.forEach((w) =>
            // send sync here 
          );
          break;
        }
      }
    };
```
To send the sync, I actually need a bit of a hack, because the “port” property cannot be serialized, so I stringify it and parse it back. Because I’m lazy and I don’t just map the windows to a more serializable array:
```typescript
    w.port.postMessage({
      action: "sync",
      payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
    } satisfies WorkerMessage);
```
Now it’s time to draw stuff!

## The Fun Part : Drawing !

Of course, we won’t be doing complicated 3D spheres : we’ll just draw a circle in the center of each window and a line linking between the spheres!

I’ll be using the basic 2D Context of the HTML Canvas to draw, but you can use whatever you want. To draw a circle, it’s pretty simple:
```typescript
    const drawCenterCircle = (ctx: CanvasRenderingContext2D, center: Coordinates) => {
      const { x, y } = center;
      ctx.strokeStyle = "#eeeeee";
      ctx.lineWidth = 10;
      ctx.beginPath();
      ctx.arc(x, y, 100, 0, Math.PI * 2, false);
      ctx.stroke();
      ctx.closePath();
    };
```
And to draw the lines, we need to do a bit of math (I promise, it’s not a lot 🤓) by converting the relative position of the center of another window to coordinates on our current window. 
Basically, we are changing bases. I do this using this bit of math. First, we will change the base to have coordinates on the monitor and offset that by the current window screenX/screenY

![Basically we are looking for the target position after base change](https://cdn-images-1.medium.com/max/5056/1*Zg_z1aZxUE1WP-uOk1owdw.png)
```typescript
    const baseChange = ({
      currentWindowOffset,
      targetWindowOffset,
      targetPosition,
    }: {
      currentWindowOffset: Coordinates;
      targetWindowOffset: Coordinates;
      targetPosition: Coordinates;
    }) => {
      const monitorCoordinate = {
        x: targetPosition.x + targetWindowOffset.x,
        y: targetPosition.y + targetWindowOffset.y,
      };
    
      const currentWindowCoordinate = {
        x: monitorCoordinate.x - currentWindowOffset.x,
        y: monitorCoordinate.y - currentWindowOffset.y,
      };
    
      return currentWindowCoordinate;
    };
```
And as you know, now we have two points on the same relative coordinates system, we can now draw the line !
```typescript
    const drawConnectingLine = ({
      ctx,
      hostWindow,
      targetWindow,
    }: {
      ctx: CanvasRenderingContext2D;
      hostWindow: WindowState;
      targetWindow: WindowState;
    }) => {
      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      const currentWindowOffset: Coordinates = {
        x: hostWindow.screenX,
        y: hostWindow.screenY,
      };
      const targetWindowOffset: Coordinates = {
        x: targetWindow.screenX,
        y: targetWindow.screenY,
      };
    
      const origin = getWindowCenter(hostWindow);
      const target = getWindowCenter(targetWindow);
    
      const targetWithBaseChange = baseChange({
        currentWindowOffset,
        targetWindowOffset,
        targetPosition: target,
      });
    
      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(origin.x, origin.y);
      ctx.lineTo(targetWithBaseChange.x, targetWithBaseChange.y);
      ctx.stroke();
      ctx.closePath();
    };
```
And now, we just need to react to state changes.
```typescript
    // main.ts
    sharedWorker.port.onmessage = (event: MessageEvent<WorkerMessage>) => {
        const msg = event.data;
        switch (msg.action) {
          case "sync": {
            const windows = msg.payload.allWindows;
            ctx.reset();
            drawMainCircle(ctx, center);
            windows
              .forEach(({ windowState: targetWindow }) => {
                drawConnectingLine({
                  ctx,
                  hostWindow: currentWindow,
                  targetWindow,
                });
              });
          }
        }
    };
```
And as a final step, we just need to periodically check if our window changed and send a message if that’s the case
```typescript
      setInterval(() => {
        const newWindow = getCurrentWindowState();
        if (
          didWindowChange({
            newWindow,
            oldWindow: currentWindow,
          })
        ) {
          sharedWorker.port.postMessage({
            action: "windowStateChanged",
            payload: {
              id,
              newWindow,
            },
          } satisfies WorkerMessage);
          currentWindow = newWindow;
        }
      }, 100);
```
[You can find the whole code for this on this repository](https://github.com/achrafl0/multi-window-article). I actually made it a bit more abstract as I did a lot of experiments with it, but the gist of it is the same.

And if you run it on multiple windows, hopefully, you can get the same thing as this!

![The full result](https://cdn-images-1.medium.com/max/2000/1*KJHO9DmEDcTISWuCcvDpMQ.gif)

Thanks for reading !

If you found this article helpful, intersting or just fun, you can share it to your friends/coworkers/community
[You can also subscribe to my newsletter](https://notachraf.substack.com/)  It's free ! 

{% embed https://notachraf.substack.com/embed %}


## Edit: 
Some of you proposed another solution to this problem, namely using `BroadcastChannel API` and I wanted to give them a shoutout, mainly @framemuse and @axiol 
Actually @axiol did a full write up of the solution using the BroadcastChannel API that you can find here: https://github.com/Axiol/linked-windows-broadcast-api

Huge thanks to them to help everyone else learn something new (starting from me)