There might be a better way to do this, but I came up with a solution I wanted to share.
I commented to my own post in feature requests, but felt like it need to be here in Show & Tell instead for better visibility.
I came up with the idea to use localStorage
as a pretend message bus. It already has events, so it wasn't to hard to implement. At the end is the code for the LocalStorageEventManager
but first is how it is used.
index.ts
This is our main component library file. We export two components, the listener Component A
and the emitter, Component B
. They are as follows...
import React from "react";
import { Retool } from "@tryretool/custom-component-support";
import { LocalStorageEventManager } from "./LocalStorageEventManager";
// Defined a Payload
type ControlMessage = {
action: "fit" | "expandAll" | "collapseAll";
};
// Define Events with Payloads
type Events = {
CONTROL_CLICKED: ControlMessage;
};
// Create an instance with a namespace on localStorage
const LSEM = LocalStorageEventManager<Events>({ namespace: "chartControls" });
/**
* Component A
*/
export const Chart: React.FC = () => {
// Get the `useEffect` hook for an event
// The hook is strongly typed for the listener
const onControlClicked = LSEM.getListenerHook("CONTROL_CLICKED");
// Register the hook and listen for the event
// data will be `ControlMessage`
onControlClicked((data) => {
console.log("control clicked", data);
// data will be { action: "fit" | "expandAll" | "collapseAll" }
});
return ( /* your component */ );
};
/**
* Component B
*/
export const ChartControls: React.FC = () => {
// Get the emitter for an event
const emit = LSEM.getEmitter("CONTROL_CLICKED");
// emit is strongly typed emit(data: ControlMessage) => void
// emit({ action: "nope" }) will produce a typescript error
return (
<div>
<button onClick={() => emit({ action: "fit" })}>Fit To Screen</button>
<button onClick={() => emit({ action: "expandAll" })}>Expand All</button>
<button onClick={() => emit({ action: "collapseAll" })}>Collapse All</button>
</div>
);
};
This solved my problem of having two separate components communicate with each other!
LocalStorageEventManager
import { useEffect } from "react";
import type { Retool } from "@tryretool/custom-component-support";
export function LocalStorageEventManager<
T extends Record<string, Retool.SerializableType>
>(config?: { namespace: string }) {
const namespace = config?.namespace ?? `LSEM_${new Date().getTime()}`;
const context = {
loaded: localStorage.getItem(namespace),
parsed: {}
};
if (!context.loaded) {
localStorage.setItem(namespace, "{}");
context.parsed = {};
} else {
context.parsed = JSON.parse(context.loaded);
}
const getEmitter = (emitter: keyof T) => {
const emit = (data: T[keyof T]): void => {
try {
const context = {
loaded: localStorage.getItem(namespace),
parsed: {}
};
if (!context.loaded) {
localStorage.setItem(namespace, "{}");
context.parsed = {};
} else {
context.parsed = JSON.parse(context.loaded);
}
localStorage.setItem(
namespace,
JSON.stringify({
...context.parsed,
[emitter]: data
})
);
} catch (error) {
console.error("Failed to emit localStorage event:", error);
}
};
return emit;
};
const getListenerHook = (eventName: keyof T) => {
const listen = (onMessage: (data: T[keyof T]) => void) => {
useEffect(() => {
const handleStorageEvent = (event: StorageEvent) => {
if (event.key === namespace && event.newValue) {
try {
const parsedData = JSON.parse(event.newValue) as Record<
keyof T,
T[keyof T]
>;
const eventData = parsedData[eventName];
if (eventData !== undefined) {
onMessage(eventData);
// Use object destructuring to exclude eventName
const { [eventName]: _, ...updatedData } = parsedData;
localStorage.setItem(namespace, JSON.stringify(updatedData));
}
} catch (error) {
console.error("Failed to parse localStorage message:", error);
}
}
};
window.addEventListener("storage", handleStorageEvent);
return () => {
window.removeEventListener("storage", handleStorageEvent);
};
}, [namespace, eventName, onMessage]);
};
return listen;
};
return { getEmitter, getListenerHook };
}