-->
Strategies for communicating between interactive components using Islands Architecture, with examples using Astro and React.
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.
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:
This site is mostly static content, except for a few interactive components:
<Quiz />
components can appear in the body of the page with questions for the reader to answer.<ProgressTracker />
component appears in the sidebar, showing the number of quizzes the reader has completed so far.<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:
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.
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.
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.
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:
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:
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:
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:
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.
// ...
<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.
This approach could be problematic, so let’s explore which other options we have.
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.
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:
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:
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:
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.
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:
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:
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:
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:
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:
Quiz
is more reusable. We could listen to the event that Quiz
dispatches and do any operation we want when a quiz is completed—it’s no longer limited to just updating a value in the store.Quiz
is more portable. Since the store is no longer a dependency of Quiz
, we can move this component around much more easily. For example, we could put it in a shared component library without having to worry about bringing the store with it.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.
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.
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.