---
title: "Widget state synchronisation across tabs"
description:
  "How we implemented widget state synchronisation in NeetoChat widget."
canonical_url: "https://www.bigbinary.com/blog/widget-synchronisation"
markdown_url: "https://www.bigbinary.com/blog/widget-synchronisation.md"
---

# Widget state synchronisation across tabs

How we implemented widget state synchronisation in NeetoChat widget.

- Author: Labeeb Latheef
- Published: July 2, 2024
- Categories: React, JavaScript

The NeetoChat widget is the end-user-facing companion widget of our
[NeetoChat](https://www.neeto.com/neetochat) application. By embedding the
NeetoChat widget on their website, NeetoChat users can easily interact with
their customers in real-time.

![NeetoChat chat screen](https://www.bigbinary.com/blog/images/images_used_in_blog/2024/widget-synchronisation/neeto-chat.png)

The NeetoChat widget has a feature to synchronize its state across other widget
instances you might have open in any other tab or window, in real-time. This
ability gives users the illusion of interacting with the same widget instance
across tabs and provides a sense of continuity. For example, user interactions
such as navigating to another page, and minimizing/maximizing the widget
performed on a widget instance in one tab are reflected across widgets in every
other tab.

In fact, a widget or the underlying script cannot be shared between multiple
browser contexts (tabs, windows, etc). All scripts in a context run in an
isolated environment, strictly separate from other executing contexts. However,
there are methods we can use to enable communication between two browser
contexts. Some of the popular choices are listed below. With a proper
communication channel set in place, two widget instances can notify each other
about their user interactions and state updates in real time to be synchronized.

1. Use **BroadcastChannel** API
2. Use **localStorage** change listener
3. Use **window.postMessage()** method
4. Use Service Worker Post Message

With ease of implementation and compatibility across different browser
environments in mind, we have chosen to implement this feature using the
localStorage change listener. The details of the implementation can be split
into two parts.

##### 1. Navigation Checkpoint:

When opening a new widget instance in a new tab or window, the widget resumes
from the last checkpoint, allowing the users to continue from where they left
off. This implementation is pretty straightforward. Whenever the URL pathname
changes, we keep a reference in the localStorage called "checkpoint".

```jsx
const Router = ({ children }) => {
  const history = useHistory();
  const pathname = history.location.pathname;

  const addCheckPoints = pathname => {
    // Check if the current pathname matches any allowed routes.
    // Only navigations to main pages are allowed to be added as checkpoints.
    const isAllowed = ALLOWED_CHECKPOINT_ROUTES.some(route =>
      matchPath(pathname, { path: route, exact: true, strict: true })
    );
    // Writes the pathname to localStorage with a unique identifier
    if (isAllowed) localStorage.setItem("checkpoint", pathname);
  };

  // Run for every pathname changes
  useEffect(() => {
    addCheckPoints(pathname);
  }, [pathname]);

  return <App />;
};
```

Now, upon initializing a new widget instance, we check localStorage for any
existing checkpoint and set this as the initial route, presenting the user with
the same screen they visited last time.

```jsx
const history = useHistory();

// Run on initial mount
useEffect(() => {
  const checkpoint = localStorage.getItem("checkpoint");
  // Replace initial route with checkpoint from localStorage.
  if (checkpoint) history.replace(checkpoint);
}, []);
```

##### 2. Real-time Synchronisation:

The above implementation only allows a new widget instance to start in the last
checkpoint. From this point onwards, each state updates (minimize, maximize),
and user navigation in one widget has to be synchronized in real time with
active widget instances in other tabs to maintain the same appearance across the
tabs. At the core, this communication is enabled by adding a custom React hook
called `useWindowMessenger`. The `useWindowMessenger` hook relies on
localStorage values and localStorage change listeners for sending messages or
events across different browser contexts.

```javascript
const storageKey = `__some_unique_localStorage_key__`;
const origin = window.location.origin;
const windowId = uuid(); // Assigns unique id for each browser contexts.

const createPayload = message => {
  const payload = {};
  payload.message = message;
  payload.meta = {};
  payload.meta.origin = origin;
  payload.meta.window = windowId;
  return JSON.stringify(payload);
};

const useWindowMessenger = messageHandler => {
  const messageHandlerRef = useRef();
  messageHandlerRef.current = messageHandler;

  const sendMessage = useCallback(message => {
    const payload = createPayload(message);
    // A new item is updated in local storage and immediately removed,
    // This is sufficient to get the 'storage' event to be fired.
    localStorage.setItem(storageKey, payload);
    localStorage.removeItem(storageKey);
  }, []);

  useEffect(() => {
    if (typeof messageHandlerRef.current !== "function") return;

    const handleStorageChange = event => {
      if (event.key !== storageKey) return;
      if (!event.newValue) return;
      const { message, meta } = JSON.parse(event.newValue);
      // Every window has a unique `windowId` attached.
      // If event originated from the same `window`, the event is ignored.
      if (meta.window === windowId || meta.origin !== origin) return;
      messageHandlerRef.current(message);
    };

    // `storage` event is fired whenever value updates are sent to localStorage
    window.addEventListener("storage", handleStorageChange);
    return () => {
      window.removeEventListener("storage", handleStorageChange);
    };
  }, [messageHandlerRef]);

  return sendMessage;
};

export default useWindowMessenger;
```

In essence, the useWindowMessenger hook returns a `sendMessage` function that
can be used to send a message to widget instances in other tabs. Also, it
accepts a `messageHandler` callback that can receive and handle the messages
sent by other instances.

Now, when one widget instance emits state and navigation change events, other
widget instances handle these events to make necessary updates to their internal
state to mirror the changes. Below is a simplified example of how this was done
in the NeetoChat widget.

```javascript
import { useCallback } from "react";
import { useHistory } from "react-router-dom";

// useLocalStorage is an in-house implementation that sync its state value into localStorage and restores the last value on next load.
import useLocalStorage from "@hooks/useLocalStorage";
import useWindowMessenger from "@hooks/useWindowMessenger";

export const MESSAGE_TYPES = {
  STATE_UPDATE: "STATE_UPDATE",
  PATH_UPDATE: "PATH_UPDATE",
};

const useWidgetState = () => {
  // This internal state controls widget visibility and other behaviours.
  const [widgetState, setWidgetState] = useLocalStorage(
    "widgetState", // Unique localStorage key
    { maximized: false }
  );
  const history = useHistory();
  const pathname = history.location.pathname;

  const sendMessage = useWindowMessenger(message =>
    handleMessageTypes(message.type, message.payload)
  );

  const handleMessageTypes = (type, payload) => {
    switch (type) {
      // Actions such as minimize, maximize are received as "STATE_UPDATE"
      case MESSAGE_TYPES.STATE_UPDATE:
        // Payload contains new state values
        // Commit new value updates to the internal state that controls widget.
        setWidgetState(prevState => ({ ...prevState, ...payload }));
        break;

      // User navigation actions are received as "PATH_UPDATE"
      case MESSAGE_TYPES.PATH_UPDATE:
        // Payload contains new pathname
        // Navigate to page if not already in the same page.
        if (history.location.pathname !== payload) history.push(payload);
        break;
      default:
        console.warn(`Unhandled message type: ${type}`);
    }
  };

  // This function extends `setWidgetState` function by adding the ability to emit `STATE_UPDATE` event for each state update call.
  const updateWidgetState = useCallback(async update => {
    let nextState;
    await setWidgetState(prevState => {
      nextState = { ...prevState, ...update };
      return nextState;
    });
    sendMessage({ type: MESSAGE_TYPES.STATE_UPDATE, payload });
  }, []);

  // Send "PATH_UPDATE" event for every path changes in the active widget.
  useEffect(() => {
    sendMessage({
      type: MESSAGE_TYPES.PATH_UPDATE,
      payload: pathname,
    });
  }, [pathname]);

  return [widgetState, updateWidgetState];
};
```

With the above setup, all the widget instances in different tabs act like a
single widget by mirroring the actions from the active instance.

## Links

- [Human page](https://www.bigbinary.com/blog/widget-synchronisation)
