20 min read
Edit pageChapter 4: The Secret Life of Astro Component Islands
Component islands are the secret to Astro’s super-fast narrative. It’s time to learn everything about them.
What you’ll learn
- Hands-on experience working with framework components in Astro.
- Responsible hydration and why it matters.
- How component islands work in Astro.
- Why islands are essential.
How islands work in Astro
Assume we’ve got an Astro application with static content: a navigation bar, some main content, a footer and a side pane.
If we need to introduce some interactivity content in the side pane of the application, how could we achieve this?
Astro provides the following ways to do this:
- We’ve seen how this works: introduce a
<script>
element to handle interactivity within your Astro component. - Use a supported framework component, and leverage a component island.
The second option is the focus of this chapter.
At the time of writing, Astro lets you use components built with React
, Preact
, Svelte
, Vue
, SolidJS
, AlpineJS
or Lit
in your Astro components. Moving on, I’ll refer to these as framework components.
So, why would we use framework components and not just provide native support via a <script>
element?
It would be best to stick with a <script>
element in cases where you can get by with vanilla Javascript or Typescript. However, there are cases where we may favour a framework component. For example:
-Design systems: using a pre-existing design system in an Astro project can save time, depending on the use case. It also helps keep all your applications looking and feeling the same way.
-Open-source: we might consider utilising a feature-rich open-source framework component already existing instead of building some highly interactive component from scratch. This way, we can easily use an open-source framework component in Astro.
-Ease of development: we may find building richer stateful user interfaces easier, more manageable, and faster to implement via framework components than vanilla Javascript / Typescript provided in <script>
.
To use a framework component in Astro, we leverage component islands.
Let’s return to our example application.
Assuming we’ve weighed the pros and cons and decided to introduce a framework component, the following section highlights the steps to take.
Step 1: Build an Astro site
We can’t use framework components without having some Astro site to use them in.
We’ve already seen how to build static sites with Astro, so creating a new static project is unnecessary. Instead, let’s start a new Astro with a project I’ve prepared.
Clone the project:
Then, install dependencies and start the application via the following:
This will run the project in one of your local ports.
The project takes the same form as our hypothetical example — it’s got a navigation, main content, footer and side pane.
Within the side pane, there’s a slot
to render our interactive content via a framework component.
In src/pages/index.astro
, you’ll find the code responsible for rendering the page as shown below:
DefaultIslandLayout
provides the layout for the entire page and includes a slot
for rendering whatever children elements are passed to it. Initialise the project locally and take a look!
Step 2: Install the framework integration
Astro provides official integrations for the supported framework components. In this example, we’ll use the react
framework.
It’s important to note that the steps described here are the same regardless of the framework component of your choosing. Therefore, I’m sticking to react
as many more developers arguably use it.
The most convenient way to add your framework integration is to use the astro add
command, e.g., to add react
, run the following commands:
This will automatically add the relevant framework dependencies to our project.
The command will also automatically update our project configuration, astro.config.mjs
, to include the framework integration.
Essentially, this breaks down the installation of a framework into our Astro project into two distinct processes:
- Install the framework dependencies.
- Add the relevant framework integration in the project config file.
If we didn’t use the Astro add
command, we could achieve the same results manually by installing the framework dependencies and adding the framework integration in our project configuration file.
Step 3: Write the component framework
Our framework component will be a glorified counter. Assuming the page consists of an article a reader can upvote, we’ll build an upvote button.
Here’s the annotated UpvoteContent
React component:
Don’t worry if you don’t understand react
. The goal here is to know how to work with framework components in Astro. We could build the same component using any other framework we choose, e.g., Vue or Svelte.
Step 4: Render the component framework
Let’s go ahead and render the framework component as shown below:
-
Create a new page in
src/pages/none.astro
-
Render the
UpvoteContent
component as a child ofDefaultIslandLayout
, i.e.: -
DefaultIslandLayout
takes theUpvoteContent
child component and renders it within its layout slot.
Now, open the /none
page in the browser, and we should have the rendered UpvoteContent
component rendered.
The upvote counter is successfully rendered, but clicking the button doesn’t increase the count!
What’s going on? 🥹
It’s not a bug. It’s a feature.
By default, when you render a framework component, Astro automatically renders it to HTML ahead of time, i.e., Astro strips out all of the component JavaScript.
Essentially, you get no interactivity from framework components by default.
As it stands, what we currently have is technically not an island. We have the component markup rendered with no interactivity.
Responsible hydration
Astro helps you minimise Javascript bloat when using framework components by leveraging responsible hydration.
If Astro renders your framework component to 100%
HTML, how do you hydrate (make interactive) the framework component?
In the context of Astro development, responsible hydration refers to Astro making no decision on when to hydrate your framework component and leaving that decision entirely up to the developer.
This is powerful but comes with the burden of decision resting on us — developers.
When technical decisions such as this need to be made, they must be made against specific requirements. In this case, the decision lies in evaluating two criteria, namely priority and interactivity.
- Priority: is this a high or low-priority user interface element?
- Interactivity: should this element be interactive as soon as possible?
We may represent this on a 2d plane as follows:
There are four attributes you can pass to your rendered framework component, e.g.,
These attributes are called client directives (or, more generically, template directives). Here are the five client directives that control the hydration of your framework component:
client:load
client:only
client:visible
client:media
client:idle
client:load
client:load
should be used for high-priority interface elements that must be interactive as soon as possible.
- Priority: high
- Interactivity: high
We may go ahead and render our UpvoteContent
component as shown below:
Here are the hydration steps:
- Render the component HTML (not hydrated).
- Wait for the page to load.
- Load component Javascript.
- Hydrate component.
The load event is fired when the page has loaded, including all dependent resources such as stylesheets, scripts, iframes, and images.
It’s important to note that clicking the upvote button will not trigger any upvotes before hydration.
client:only
client:only
behaves similarly to client:load
. It should be used for elements where you want to skip server-side rendering (the component will not be initially rendered to HTML) but make it interactive as soon as it’s shown to the user on the client.
- Priority: medium (we’re okay not showing the initial component HTML)
- Interactivity: high (as soon as it’s shown to the user)
We may go ahead and render our UpvoteContent
component as shown below:
It’s essential to pass the framework name as shown above. Otherwise, Astro doesn’t know what framework Javascript to load. This is because this isn’t determined on the server.
Here are the hydration steps:
- Do not render component HTML.
- Wait for the page to load.
- Load component Javascript.
- Hydrate component.
The difference between client:only
and client:load
is whether to render a static component HTML before the element is interactive. client:only
is particularly handy when rendering components requiring client (browser) APIs.
client:visible
client:visible
should be used for low-priority interface elements below the fold (far down the page) or resource-intensive; you don’t want to load them if the user never sees the component.
- Priority: low
- Interactivity: low
We may go ahead and render our UpvoteContent
component as shown below:
Note that I’m importing a different LargeMainContentLayout
layout in the code block above. The layout is responsible for pushing the island off the initial viewport.
Here are the hydration steps:
- Render component HTML.
- Wait for the element to be visible (uses
IntersectionObserver
). - Load component Javascript.
- Hydrate component.
client:media
client:media
should be used for low-priority interface elements only visible on specific screen sizes, e.g., sidebar toggles.
- Priority: low
- Interactivity: low
We may go ahead and render our UpvoteContent
component as shown below:
Here are the hydration steps:
- Render component HTML
- Check if the media query matches
- Load component Javascript
- Hydrate component
client:idle
client:idle
should be used for low-priority interface elements that don’t need to be immediately interactive.
- Priority: medium
- Interactivity: medium (lower priority in comparison to
client:load
)
We may go ahead and render our UpvoteContent
component as shown below:
Here’s the hydration step visualised:
- Render component HTML.
- Wait for the page to load.
- Wait for the
requestIdleCallback
event to be firedIf
requestIdleCallback
isn’t supported, use only the documentload
event. - Load component Javascript.
- Hydrate component.
Using multiple frameworks
Theoretically, we can use multiple framework components in an Astro application. This is a powerful feature, but it shouldn’t be abused.
It does make for powerful demos of what’s possible with Astro. However, there are only a few real-world cases where we might want to do this, e.g., composing autonomous micro frontends on an Astro page.
Within an Astro component, the following is valid:
Let’s see a real example in practice.
An upvote counter in Vue
Recall that we built the initial UpvoteContent
component using React. We’ll now create the UpvoteContent
component using Vue and render both components in our Astro project.
Here’s the annotated implementation:
And that’s it!
Rendering different framework components
The rendering process for framework components is essentially the same. Let’s go ahead and render the React and Vue UpvoteContent
components on a new page, as shown below:
- We create a new page in
pages/multiple-frameworks.astro
. - We import both React and Vue components.
- We render both components in an identical pattern and with the same client directive,
client:load
.
It’s also essential to add Vue support to the project by running the following:
This will install the relevant Vue dependencies and add the integration support in the Astro config file.
Once that’s done, we may view the running application on route /multiple-frameworks
.
As expected, both components are rendered and work just as expected.
Sharing state between component islands
As we work with component islands in Astro, you will inevitably need to share certain application states between component islands.
For example, let’s assume we want our UpvoteContent
components to share the same counter values.
Regardless of the component framework, every framework has its construct for sharing UI state between components, e.g., between React or Vue components.
However, when working within Astro components, we need a solution that works framework agnostic, i.e., not tied to a single framework.
Here are some tremendous framework-agnostic solutions we can choose from:
- Signals: These are great for expressing state based on reactive principles. We may use signals from Preact, signia from tldraw or Solid signals outside a component context.
- Vue’s reactivity API: This can be an excellent ready-to-use solution if you already utilise Vue components in your Astro project.
- Svelte’s stores: This can also be a great out-of-the-box solution if you already use Svelte components in your Astro project.
- Nano stores: This is a tiny framework-agnostic library for state management.
In this example, we’ll use Nano stores mainly because they are lightweight (less than 1kb) and don’t add a lot of Javascript footprint to our application.
How nano store works
At a high level, what we’re trying to achieve is to remove the state values from within our framework components and manage them via nanastores
.
We’ll create a new upvoteCounter
state variable within nanostore. We will then propagate changes to this state variable to our framework components.
Install nano store
To use nanostore, we must install the library into our project. Run the following installation command:
nanostores
represents the base library for creating and managing our state values- To guarantee that the framework component is re-rendered whenever a state value changes, we will use the React and Vue integrations for nanostores through
@nanostores/react
and@nanostores/vue
, respectively.
Create the state value
Our example includes sharing the upvote count value across multiple framework components.
To create a state value, nanostores use atoms to store strings, numbers, and arrays.
Let’s create an atom to hold the counter state variable:
- We create a new file in
src/stores/upvote.ts
. - We import
atom
fromnanostore
. - We create a new state number value called
upvoteCountStore
.
We may think of atoms as small pieces of state to be shared across components in our application.
Using the state value in framework components
In the React component, we will leverage the useStore
hook provided in @nanostores/react
to retrieve the state value from the upvoteCountStore
:
The code has been annotated for ease of comprehension. Take a look.
With the Vue component, we may leverage props
for reactivity as shown below:
Lovely!
Now, if we try the application, both framework components should have synced upvote values!
Passing props and children to framework components
Most framework components support receiving data via props and children. These are equally supported when rendering framework components in Astro.
For example, we currently have the upvote button label hardcoded.
We could make this dynamic via props as shown below:
We’d then handle the prop in the UpvoteContent
React component as usual:
It’s important to note that we can pass any primitive as props, and they will work as expected.
However, be careful with function props. Function props will only work during server rendering and fail when used in a hydrated client component, e.g., as an event handler. This is because functions cannot be serialised (transferred from the server to the client).
Children are often treated as a prop type - depending on the framework component used. For example, React, Preact and Solid use the special children
prop, while Svelte and Vue use the <slot />
element. These are both supported when working with framework components in Astro.
For example, with our React <UpvoteContent />
component, we could go ahead and receive a component description as children
:
This will change nothing until we explicitly handle the children
prop within the <UpvoteContent>
component, as shown below:
With our Vue <UpvoteContent />
component, we could equally receive a component description as children:
However, we must reference this via a <slot>
element. This is a fundamental difference in how libraries like React / Preact and Vue / Svelte deal with references to the children prop.
Here’s how to reference the children element in UpvoteContentVue
:
Additionally, we may use multiple slots to group and reference children within our framework components.
Consider the following example with multiple children elements:
Note that we have two children nodes referenced by the slot names social-links
and description
, respectively.
Within <UpvoteContent />
, we may reference these separately as shown below:
It is important to note that the kebab-case
slot names in the Astro component are referenced as camelCase
values on the props
object.
In Svelte and Vue, the slots will be referenced using a <slot>
element with a name
attribute. Here’s the implementation in <UpvoteContentVue />
:
Please note how the slot kebab-case
names are preserved.
Nested framework components
In an Astro file, we may also nest framework components, i.e., pass framework components as children. For example, the following is valid:
As expected, this renders the nested UpvoteContent
component:
Recursively rendering the same component is rarely the goal we want to achieve. However, rendering nested framework components is powerful because we can compose an entire framework component application as we see fit.
Astro Island gotchas
As developers, we are often responsible for inadvertently breaking things. Although debugging can be an enjoyable challenge, consider the following boundaries with Astro Islands.
1. Do not use an Astro component in a framework component
Consider the following example of importing a .astro
component and rendering it within a React component:
This is an invalid use. The reason is that the React component is rendered a React “island”. Consequently, the island should contain only valid React code. This is the same for other framework component islands.
To overcome this, consider using the slot pattern earlier discussed to pass static content from an Astro component:
2. Do not hydrate an Astro component
Consider the following naive example to hydrate an Astro component using a client directive:
This is invalid. Astro components have no client-side runtime. However, use a <script>
tag if you need to interactivity.
Why islands?
Typically, most materials would place this section at the start of the chapter. However, there are certain instances where it’s more beneficial to showcase practical use cases before diving into the reasons behind them. In addition, this approach could foster an intuitive understanding, which is what I’ve adopted here.
So, why focus on islands? What advantages do they offer?
1. Performance
One of the main advantages is improved performance. We can significantly enhance our site’s speed by converting most of our website to static HTML and selectively loading Javascript through islands only when necessary. This is because Javascript is one of the slowest assets to load per byte.
2. Responsible hydration
If Javascript is expensive to parse and execute, the decision to load it should be carefully taken (from a performance perspective). Also, no one solution fits all application types and use cases. As such, controlling when a component island is hydrated puts you in charge of your website performance.
3. Parallel loading
Lastly, it’s essential to utilise parallel loading. This means that when we load several islands, they won’t have to wait for each other to become hydrated. Instead, each island is considered a distinct unit that loads and becomes hydrated independently, in isolation.
Conclusion
In this chapter, we learned about component islands in Astro and how they work. We also explored why framework components are sometimes preferred over vanilla Javascript or Typescript via a <script>
element.
We also went through the steps to use a framework component in an Astro application, including building a static site, installing the framework, and writing the component. Finally, we experimented using a React and Vue component to demonstrate the use of framework components. See you in the next chapter!