go back

Placeholder for post cover image
Cover image for post

How to reflect hook state across islands 🏝

July 22, 2022

"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];
}
Enter fullscreen mode Exit fullscreen mode

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');
        };
    }, []);
}
Enter fullscreen mode Exit fullscreen mode

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!

go back