DEV Community

Joel Humberto Gómez Paredes
Joel Humberto Gómez Paredes

Posted on

Sincronizando data entre tabs

Una de las cosas mas complicadas de entender y manejar en una aplicación es el estado de una aplicación. Aunque tenemos herramientas como Redux, Zustand o cualquier otra biblioteca JS que salga en los próximos minutos que te tome leer este post.

Pero este post no se trata de bibliotecas, se trata de la plataforma y como sincronizamos datos entre las diferentes tabs que pueden estar abiertas en el navegador.

El navegador administra la memoria de una tab de manera individual pero la capa de datos se administra por dominio. Tenemos varias alternativas para almacenar datos:

Hay muchas formas para almacenar datos y cada una de estas tiene un propósito. Ejemplo, la Cache API esta hecha para almacenar la respuesta de requests de red, lo cual nos sirve features como el modo offline.

Antes de hablarles de como sincronizar datos entre tabs, me gustaría dejar claro algunos puntos.

  1. No hay mecanismo que el browser brinde de manera nativa una sincronización entre tabs.
  2. No toda la información debe sincronizarse.
  3. Normalmente usamos el servidor como fuente de la verdad y esta bien pero hacer multiples requests para tener la misma información que ya tienes en una tab me parece innecesario.

Para ello exploraremos 2 approaches:

LocalStorage

Esta opción es la menos recomendada porque es síncrona y ocupa el thread principal de ejecución de JS, pero siendo honestos es uno de los storages mas utilizados.

El objeto window tiene asociado un evento "storage", el cual se ejecuta cada vez que el storage cambia. Este se ejecutará en cada uno de nuestros tabs entonces con simplemente suscribirnos al evento podemos saber si algo cambió (se ejecuta en todos menos en el tab donde se ejecutó el cambio).

Vamos a hacer una página simple donde tengamos un contador y un botón para incrementar. El requerimiento es que cada vez que de click el contador se incremente se refleje en todas las tabs que tengamos abiertas.

Contador

Nota: Sugiero usar un folder y serve como servidor estático

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sync local storage</title>
  </head>
  <body>
    <h1 id="counter">0</h1>
    <button id="increase">Increase</button>
    <script>
      document.getElementById("increase").addEventListener("click", () => {
        const counter = +localStorage.getItem("counter", 0) + 1;
        document.getElementById("counter").textContent = counter;
        localStorage.setItem("counter", counter);
      });
      window.addEventListener("storage", (event) => {
        if (event.key !== "counter") return;
        document.getElementById("counter").textContent = +localStorage.getItem(
          "counter",
          0
        );
      });
      window.addEventListener("load", () => {
        document.getElementById("counter").textContent = +localStorage.getItem(
          "counter",
          0
        );
      });
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

La parte importante de esta página es suscribirse al evento de cambio del localStorage.

 window.addEventListener('storage', (event) => {
      if (event.key !== 'counter') return;
      document.getElementById('counter').textContent = +localStorage.getItem('counter', 0);
    });
Enter fullscreen mode Exit fullscreen mode

El evento storage tiene una propiedad llamada key, mediante la cual podemos saber que cambió, este paso es crucial porque el storage es solo uno. Entonces esta al alcance de extensiones o cualquier otro script que se ejecute en el contexto de la aplicación. En este evento podríamos poner dispatchers o lo que queramos que reflejo el cambio que queremos a nuestro state manager.

La ventaja de este approach es que es fácil, ya tenemos un sistema de eventos incluido que evita la redundancia (evita disparar el evento en la tab donde se ejecutó)

La desventaja ... es el localStorage, todos tienen acceso y es síncrono, además de que solo puedes guardar strings así que debes hacer parseos para guardar estados complejos y si quieres ejecutar acciones solo para ciertas partes de tu state manager tendrás que verificar que solo se disparen cambios si la data cambió (algunos state managers ya incluyen esta funcionalidad).

Cualquier Storage + Service Workers

Este approach es un poco más complicado pero en lo personal lo prefiero, por la simple y sencilla razón de que no es el local storage.

Para esto necesitamos una pieza de software centralizada que actue como un pubsub para la administración de eventos. En este caso usaremos un service worker ya que es una instancia compartida por todas las tabs (clients).

Las tareas del service worker serán las siguientes:

  1. Suscribirse a un evento que nos diga que se quiere hacer un incremento
  2. Hacer un update en nuestra base de datos (en este caso usaremos indexeddb)
  3. Mandar un evento que le indique a todos los tabs(clients) que hubo un incremento.

Este service worker cumple con ese objetivo

self.addEventListener('message', (event) => {
  if (event.data === 'request_increment') {
    incrementValueInDB();
  }
});

function incrementValueInDB() {
  const request = indexedDB.open('APPDB', 1);

  request.onupgradeneeded = function(event) {
    const db = event.target.result;
    db.createObjectStore('count', { keyPath: 'id' });
  };

  request.onsuccess = function(event) {
    const db = event.target.result;
    const transaction = db.transaction(['count'], 'readwrite');
    const objectStore = transaction.objectStore('count');
    const request = objectStore.get(1);

    request.onsuccess = function(event) {
      let count = event.target.result?.count || 0;
      count++;
      const requestUpdate = objectStore.put({ count, id: 1 }, 1);
      requestUpdate.onsuccess = function() {
        self.clients.matchAll().then((clients) => {
          clients.forEach((client) => client.postMessage('has_increment'));
        });
      };
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

El primer paso consiste en suscribirse a los mensajes y filtrarlos, ya que al ser una instancia compartida muchos mensajes pueden llegar.

self.addEventListener('message', (event) => {
  if (event.data === 'request_increment') {
    incrementValueInDB();
  }
});
Enter fullscreen mode Exit fullscreen mode

La segunda parte es el código donde se actualizará la base de datos. Los service workers tienen acceso a IndexedDB así que ahí hacemos el incremento (aquí depende de la API que quieran usar, por eso no hay código)

La tercera parte consiste en avisar a los demas que hubo un incremento.

self.clients.matchAll().then((clients) => {
          clients.forEach((client) => client.postMessage('has_increment'));
        });
Enter fullscreen mode Exit fullscreen mode

Listo, con esto terminamos el service worker y ahora les comparto el código del HTML/JS

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sync local storage</title>
  </head>
  <body>
    <h1 id="counter">0</h1>
    <button id="increase">Increase</button>
    <script>
      document.getElementById("increase").addEventListener("click", () => {
        navigator.serviceWorker.controller.postMessage('request_increment');
      });

      if ("serviceWorker" in navigator) {
        window.addEventListener("load", function () {
          const request = indexedDB.open('APPDB', 1);

          request.onupgradeneeded = function(event) {
            const db = event.target.result;
            db.createObjectStore('count', { keyPath: 'id' });
          };

          request.onsuccess = function(event) {
            const db = event.target.result;
            const transaction = db.transaction('count', 'readwrite');
            const store = transaction.objectStore('count');
            const getRequest = store.get(1);

            getRequest.onsuccess = function(event) {
              const data = event.target.result;
              const count = data ? data.count : 0;
              document.getElementById('counter').textContent = count;
            };
          };
          navigator.serviceWorker.register("/sw.js").then(
            function (registration) {
              navigator.serviceWorker.addEventListener('message', function(event) {
                if (event.data === 'has_increment') {
                  const request = indexedDB.open('APPDB', 1);
                  request.onsuccess = function(event) {
                    const db = event.target.result;
                    const transaction = db.transaction('count', 'readwrite');
                    const store = transaction.objectStore('count');
                    const getRequest = store.get(1);
                    getRequest.onsuccess = function(event) {
                      const data = event.target.result;
                      const count = data ? data.count : 0;
                      document.getElementById('counter').textContent = count;
                    };
                  };
                }
              });
            },
            function (err) {
              console.log("ServiceWorker registration failed: ", err);
            }
          );
        }); 
      }
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

En la aplicación el código mas importante es la parte de enviar el evento de incrementar

document.getElementById("increase").addEventListener("click", () => {
  navigator.serviceWorker.controller.postMessage('request_increment');
});
Enter fullscreen mode Exit fullscreen mode

Y suscribirnos al evento "has_increment" para tomar el dato del local storage

navigator.serviceWorker.addEventListener('message', function(event) {
                if (event.data === 'has_increment') {
                  const request = indexedDB.open('APPDB', 1);
                  request.onsuccess = function(event) {
                    const db = event.target.result;
                    const transaction = db.transaction('count', 'readwrite');
                    const store = transaction.objectStore('count');
                    const getRequest = store.get(1);
                    getRequest.onsuccess = function(event) {
                      const data = event.target.result;
                      const count = data ? data.count : 0;
                      document.getElementById('counter').textContent = count;
                    };
                  };
                }
              })
Enter fullscreen mode Exit fullscreen mode

Nota: Si quieres hacer alguna modificación se cauteloso porque los service workers son difíciles de depurar

La ventaja de este enfoque es que no se bloquea el main thread de JS ya que la parte de actualización de datos se hace directamente en el worker y que es hasta cierto punto agnóstica ya que podemos usar cualquier store accesible por un worker.

La desventaja es que tiende a ser un poco complicado

Conclusión

Ambos enfoques funcionan, dependerá de la arquitectura de la aplicación el decidir cual usar

Top comments (2)

Collapse
 
joelop3 profile image
Joel Outeiro Pérez

Que hay del Broadcast Channel API?

Collapse
 
dezkareid profile image
Joel Humberto Gómez Paredes

También serviría