"Island" architecture is a term coined relatively recently that describes frontends composed of multiple entrypoints. It challenges the traditional approach of rendering a giant tree of components, enabling more clear isolation of dynamic, hydratable elements from static content. And it's baked into Fresh, a new framework for Deno which I'm currently using for a project (coming soon)!
But with this isolation comes limitations that inhibit regular patterns, like shared state. In this walkthrough I'll cover how I managed to synchronize hook state across different islands and keep my application's logic organized.
The hook in question ☀️/🌙
To enable a dark mode preference in my project I added this simple hook to interface with "prefers-color-scheme: dark"
, adding a dark
class to the body
element and setting this in localstorage
to persist any preference override:
export function useDarkMode() {
const [dark, setDark] = useState(false);
function toggleDarkMode() {
const prefersDark = document.body.classList.toggle('dark');
setDark(prefersDark);
localStorage.setItem('prefers-dark', prefersDark);
}
useEffect(() => {
const prefersDark = localStorage.getItem('prefers-dark') === 'true';
const devicePrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if ((prefersDark === null || prefersDark) && devicePrefersDark) {
toggleDarkMode();
}
}, []);
return [dark, toggleDarkMode];
}
This works for the render tree that has the button that triggers toggleDarkMode
, but due to the island approach this render tree (and the state within) is completely isolated from others. To ensure all elements are in the correct dark
state regardless of entrypoint there needs to be a lifeline between the islands.
Enter: event dispatching 🛟
While there are many approaches to solving this issue (MutationObserver
, etc), the simplest is to dispatch an event that other instances of this hook can listen for.
In this case, this triggers each island to call the toggleDarkMode
function and (with the proper conditions) keep its state in sync with the triggering instance of the hook. Here are the modifications needed for the above hook to achieve this:
export function useDarkMode() {
function toggleDarkMode() {
// same code as above
window.dispatchEvent(new Event('dark-mode-preference-updated'));
}
function respondToEvent() {
const prefersDark = document.body.classList.contains('dark');
setDark(prefersDark);
}
useEffect(() => {
const prefersDark = localStorage.getItem('prefers-dark') === 'true';
const devicePrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if ((prefersDark === null || prefersDark) && devicePrefersDark) {
if (!document.body.classList.contains('dark')) {
toggleDarkMode();
} else if (!dark) {
setDark(true);
}
}
window.addEventListener('dark-mode-preference-updated', respondToEvent);
return () => {
window.removeEventListener('dark-mode-preference-updated');
};
}, []);
}
To summarize: each hook instance will, upon mount, check for a user's color scheme preference and set that value in state by calling the same function.
Then, any calls to toggleDarkMode
will fire an event for every other instance of the hook to receive, which causes each to check the value on body
and store that in state without performing any mutations.
The localstorage
value is only set by the triggering hook so subsequent pageloads will get the correct preference value.
While this may contradict some of what hooks aim to simplify regarding shared state, it allows logic shared across components to live in a single place. The implementation for this hook is simplified by document.body.classList
being the source of truth, but more complex events can be used to keep data in sync across instances. Anyways, let me know what you think & if you have other suggestions for mirroring state across different entrypoints!