Sharing State with Islands Architecture

Strategies for communicating between interactive components using Islands Architecture, with examples using Astro and React.

By Maxi Ferreira /

Islands Architecture is one of my favorite rendering patterns for building websites today. Compared to traditional server or client-side rendering, Islands gives us the power to choose which parts of the website are interactive, so we can render the rest of the page—the static parts—only on the server.

Islands Architecture is a fantastic way of building websites that ship less JavaScript, but it does come with a tradeoff: communication between islands is not as straightforward as in other rendering patterns.

Thankfully, we can solve this challenge in a number of different ways. Let’s explore some of the options we have.

Building an Interactive Tutorial

Building an Interactive Tutorial

Let’s start with an example to illustrate the problem we’re trying to solve.

Imagine we just launched the 🔥 hottest new JavaScript framework 🔥, and we’re building an interactive tutorial to teach people how to use it. The tutorial has several steps, there are quiz questions throughout each step, and readers must answer all of them before they can advance to the next one.

Here’s how the site’s layout looks like:

Wireframe with application layout showing the static and interactive components
Layout of our interactive tutorial website

This site is mostly static content, except for a few interactive components:

  1. Several <Quiz /> components can appear in the body of the page with questions for the reader to answer.
  2. A <ProgressTracker /> component appears in the sidebar, showing the number of quizzes the reader has completed so far.
  3. A <NextButton /> component appears in the footer; it’s disabled by default and enabled once all quizzes have been completed.

Islands Architecture is a great fit for this site because we can hydrate (render on the client) only those three components, leaving the rest of the page as static HTML. Here’s how our component tree might look like:

Diagram showing the tree structure of the application
Initial component tree

We have a few options for implementing this design. Astro, Marko, and Enhance are some of the most popular frameworks that support this architecture, and React Server Components offers a similar approach.

In this article, we’ll use Astro with React as our UI framework, but you should be able to apply these patterns with your framework of choice.

Now for the challenge: our islands (the interactive components) need to share some state, so how do we get them to talk to each other? Let’s dive into the options we have.

Lifting State Up

Lifting State Up

The traditional solution to this problem is to look for the closest parent component and put the state there. Once we’ve lifted our state up, we can pass it down the tree either via prop-drilling or using something like React Context.

Diagram showing the tree structure of the application with shared state at the top
Our component tree after lifting state up to the closest common parent

In our example, the closest common parent is the Tutorial component, which is at the very top of the tree.

This presents a problem because putting the shared state at the top of the tree means that we would need to hydrate the entire page. This sort-of defeats the purpose of using an Islands Architecture, so let’s see what other options we have.

Lifting state up is not a good fit for our particular problem, but that doesn’t mean it’s a bad pattern for an Island Architecture. If your closest common parent is not at the top of the tree but on a nested subtree, lifting state up could be a viable option.

Custom Events

Custom Events

If you’re looking for a dependency-free solution that works with any framework, custom event handlers might be a good option to explore. Here’s what the design might look like with this approach:

Diagram showing the tree structure of the application sharing state via event handlers
Our component tree communicating across interactive components via event handlers

The Quiz component triggers an event whenever a quiz is complete. Then, any other components that are interested in this event can register to it and update their local state.

Here’s some pseudo-code that illustrates how this could be implemented. First off, the Quiz component needs to dispatch an event whenever a quiz is completed:

Quiz.jsx
function Quiz() {
  function handleQuizComplete() {
    document.dispatchEvent(new Event("quizComplete"));
  }
 
  return (
    <div>
      <h2>Quiz</h2>
      <button onClick={handleQuizComplete}>Complete</button>
    </div>
  );
}

Then we need to listen for that event. Since we have multiple components that are interested in this event, we can put the logic on a shared custom hook:

useCompletedQuizzes.js
function useCompletedQuizzes() {
  const [completedQuizzes, setCompletedQuizzes] = useState(0);
 
  useEffect(() => {
    function handleQuizComplete() {
      setCompletedQuizzes((prev) => prev + 1);
    }
 
    document.addEventListener("quizComplete", handleQuizComplete);
 
    return () => {
      document.removeEventListener("quizComplete", handleQuizComplete);
    };
  }, []);
 
  return {
    completedQuizzes,
  };
}

Finally, we can use this hook in any component that needs to know the number of completed quizzes. Here’s ProgressTracker, but NextButton would look very similar:

ProgressTracker.jsx
function ProgressTracker() {
  const { completedQuizzes } = useCompletedQuizzes();
 
  return (
    <div>
      <h2>Progress Tracker</h2>
      <p>Completed quizzes: {completedQuizzes}</p>
    </div>
  );
}

For simplicity, we’re dispatching events on the global document object here, but you could scope them to a specific DOM element if you need more control. More info here.

This version of our design is nice and decoupled, but it has one major drawback—since ProgressTracker and NextButton now have to keep track of their own state, our state no longer has a single source of truth. This could lead to extremely hard-to-debug issues if the states get out of sync somehow.

One way to reproduce this scenario is to make the NextButton component hydrate on visibility rather than at page-load time. In Astro, we can do this with the client:visible directive.

Footer.astro
// ...
 
<NextButton client:visible />

Now, if we start resolving quizzes before the NextButton component enters the viewport, we’d end up stuck in a state where the button continues to be disabled even after solving all quizzes 😬 This happens because some of the events fired before the button subscribed to them.

Application wireframes showing how combining event handlers with hydration on visibility can lead to bugs
A bug caused by combining event handlers with hydration on visibility

This approach could be problematic, so let’s explore which other options we have.

Sharing State with Stores

Sharing State with Stores

The most popular approach to solve this problem is using a shared store that interested components can subscribe to. A shared store is not the same as using a “global” store that sits at the top of the tree (e.g., a Redux store); it’s more of a shared component that sits alongside your rendering tree and that can be read/written from just about anywhere.

Diagram showing the tree structure of the application sharing state via a store
Our component tree communicating across interactive components via a shared store

We have several options for choosing a store. Astro recommends the nanostores library in their documentation, which is what we’ll use for our example, but several UI frameworks already come with a built-in solution—Svelte has Stores, Preact has Signals (as does Solid), and Vue has the Reactivity API.

As I was writing this article, I came across an example of this pattern using a state machine as a shared store. Baptiste Devessier created a really cool demo using Astro + XState that shows how this could be implemented.

Here’s what moving our state to a nanostore looks like. First off, we create a single atom to hold the number of completed quizzes and a function to update them:

store.js
import { atom } from "nanostores";
 
export const $completedQuizzes = atom(0);
 
export function completeQuiz() {
  $completedQuizzes.set($completedQuizzes.get() + 1);
}

Then, our Quiz component can simply call the completeQuiz function whenever a quiz is completed:

Quiz.jsx
import { completeQuiz } from "./store";
 
function Quiz() {
  return (
    <div>
      <h2>Quiz</h2>
      <button onClick={completeQuiz}>Complete</button>
    </div>
  );
}

Finally, we can subscribe to this store from any component that needs to know the number of completed quizzes:

ProgressTracker.jsx
import { useStore } from "@nanostores/react";
import { $completedQuizzes } from "./store";
 
function ProgressTracker() {
  const completedQuizzes = useStore($completedQuizzes);
 
  return (
    <div>
      <h2>Progress Tracker</h2>
      <p>Completed quizzes: {completedQuizzes}</p>
    </div>
  );
}

Now we get the best of both worlds: we can share state without having to hydrate the entire page, and we have a single source of truth to prevent out-of-sync bugs. This approach is a great fit for most use-cases, but there’s still one way to improve our design that could make a big difference in some cases.

Decoupling with Events

Decoupling with Events

Just to be clear, there’s nothing wrong with our previous design, but depending on the actual problem you’re solving, there’s still opportunity to improve it by decoupling it a little bit.

All of our interactive components now have the shared store as a dependency. For the ProgressTracker and NextButton components, this makes total sense. After all, their UI and behavior depend entirely on the store’s state.

But the Quiz component doesn’t need to know the store’s state; it just needs to be able to communicate that a quiz has been completed. So, instead of mutating the store directly by calling a function, we could dispatch an event and listen for that event in the store itself:

Quiz.jsx
function Quiz() {
  function handleQuizComplete() {
    document.dispatchEvent(new Event("quizComplete"));
  }
 
  return (
    <div>
      <h2>Quiz</h2>
      <button onClick={handleQuizComplete}>Complete</button>
    </div>
  );
}

This implementation of Quiz looks just like the one in our custom events example. Quiz dispatches an event instead of importing the store directly. Similarly, the implementation of the store’s consumers (ProgressTracker and NextButton) looks just like the one in the previous example:

ProgressTracker.jsx
import { useStore } from "@nanostores/react";
import { $completedQuizzes } from "./store";
 
function ProgressTracker() {
  const completedQuizzes = useStore($completedQuizzes);
 
  return (
    <div>
      <h2>Progress Tracker</h2>
      <p>Completed quizzes: {completedQuizzes}</p>
    </div>
  );
}

The interesting bit is in the store definition itself. A cool feature of nanostores is that we can hook into the store’s lifecycle. This allows us to run side-effects when the store “mounts” (when the first component subscribes to it) and “unmounts” (when the last component unsubscribes from it.) This is perfect for adding and removing event listeners:

store.js
import { atom, onMount } from "nanostores";
 
const $completedQuizzes = atom(0);
 
onMount($completedQuizzes, () => {
  document.addEventListener("quizComplete", () => {
    $completedQuizzes.set($completedQuizzes.get() + 1);
  });
 
  return () => {
    document.removeEventListener("quizComplete");
  };
});

Here’s how our slightly modified design looks like:

Diagram showing the tree structure of the application sharing state via a store and events
Combining events with a shared store to decouple the Quiz component

The tradeoff with this approach is that we’re adding a bit of indirection—the relationship between Quiz and the store is no longer explicit. But we’re getting a couple of nice benefits in exchange:

If you don’t like the idea of dispatching events because of the indirection it adds to the design, one alternative is to pass a callback to the Quiz component instead. In this case, the parent component of Quiz will be the one coupled to the store, but Quiz will remain reusable and portable because it won’t depend on the store directly.

Wrapping Up

Wrapping Up

Sharing state across interactive islands might not be as straightforward as in a traditional “full hydration” architecture, but it’s a solvable problem with many different options to explore.

Using a shared store is the most commonly used approach, but each one has its own set of tradeoffs, and one will likely be a better fit for your particular use case.

Read More

Read More
Illustration of a woman thinking deeply about something

“A software architecture newsletter that won't put me immediately to sleep? Is that even possible?”

Well, there's only one way to find out!

Join 4,000+ developers getting the latest insights from the world of software design and architecture—tailored specifically to frontend engineers. Delivered right to your inbox every two weeks.

    “Maxi's newsletter is a treasure trove of wisdom for software engineers with a growth mindset. Packed with valuable insights on software design and curated resources that keep you at the forefront of the industry, it's simply a must-read. Elevate your game, one issue at a time.”

    Addy Osmani
    Addy Osmani
    Engineering Lead, Google Chrome