I love Astro and its “islands of interactivity” model, which only hydrates (i.e. loads its JavaScript code) components coming from Vue / React / whatever if you explicitly opt in by adding a client:*
directive. These directives range from client:load
for hydrating a component immediately when the page loads, to client:media
, which only hydrates a component if a media query passed as an argument is matched.
The only thing that was missing for my use-cases, which include building modular websites, was a client:*
directive which would hydrate a component based on some truthy JavaScript variable. Something like client:if={ hydrateThis }
.
I even opened a feature request for it—which led me to discover a feature in development which would allow all kinds of custom directives. The feature has long launched, and I finally found the time to implement a custom client:if
directive in one of my projects. Since I believe other people can benefit from this, I wanted to document the code in a little tutorial.
Getting Started
I’m assuming that you have a basic understanding of Astro and an already existing Astro project if you’re looking into creating a custom client:*
directive. You will also need your favourite code editor, but that’s pretty much it.
Adding a custom directive happens in an “Integration”, where the directive gets registered during the setup-process of Astro. I like to keep my integrations in an integrations
folder in the root of my project, but you can put it wherever you want. Inside this folder, I have a separate folder for each of the integrations in the project, so I add one for the custom directive as well: astro-if-directive
. This makes sense since the directive has two parts, the function to register the directive and the directive itself, which needs to be referenced by the registration function. Within that folder, I add two files: register.js
and if.js
.
This is what the structure looks like in the end:
integrations/
└── astro-if-directive
├── if.js
└── register.js
The Registration Function
The registration function itself is comparatively simple, its only responsibility is to add the custom directive to Astro during setup:
export default () => ({
name: 'client:if', // the name of the integration
hooks: {
'astro:config:setup': ({ addClientDirective }) => {
addClientDirective({
name: 'if', // the name of the directive
entrypoint: './integrations/astro-if-directive/if.js',
});
},
},
});
Since we want to be able to use the directive as client:if
, that is the name we register it under. Keep in mind, that client:if
is the name of the Astro integration. The name of the directive is just if
, as the client:
part gets prepended by Astro, since it is a client:*
directive. If you’re curious about what other hooks and functions there are for your integrations, you can check them out in the documentation.
Please make sure that your entrypoint
includes the full path to the file based on your project root, i.e. the location of your astro.config.mjs
file, especially if you’re using a different directory structure than I am in this example.
The client:if Directive
The magic itself happens in the file we added as an entry point:
export default (load, options) => {
const callback = async () => {
if (!options.value) return;
const hydrate = await load();
await hydrate();
};
if ('requestIdleCallback' in window) window.requestIdleCallback(callback);
else window.setTimeout(callback, 200);
};
This function is modelled after the client:idle
directive (see source), with the only addition being the line if (!options.value) return;
which aborts hydration if the value passed to the directive evaluates to false
. This means that the hydration happens only after the page has loaded and is idling. If you wanted your directive to behave more like client:load
, you’d simply execute the callback immediately.
A note on the options
parameter: I couldn’t find documentation on what exactly gets passed. In my testing, it seemed that it’s an object containing the name
of the component the directive is attached to and the value
, which is whatever is added after the =
in the source code. I.e. for client:if={ hydrateThis }
it would be the value of the hydrateThis
variable.
My function matches how the default integrations handle parameters. This function runs once for every element that has the client:if
directive attached to it. If the value passed into the directive is truthy, the component gets hydrated, otherwise it won’t be. It’s as easy as that.
Another gotcha: something I noticed while developing the integration was that changes to the file would only get applied after I restarted the dev-server. So make sure to do so if you are surprised that your tweaks to the function don’t seem to have an effect! It won’t hot-reload like your other files, likely because the hook that registers it only gets called once when the dev-server starts.
Registering the Integration
The last thing we have to do is register the integration, so the custom directive actually gets added to Astro. To do so, we need to adjust our astro.config.mjs
file, which holds the project’s configuration:
import ifDirective from './integrations/astro-if-directive/register';
// https://astro.build/config
export default defineConfig({
integrations: [
// other integrations
ifDirective(),
],
// other configuration options
});
Once that’s done, all we need to do is restart our dev-server, and we can start adding the directive to our components:
---
const hydrateMe = true;
const dontHydrateMe = false;
---
<YourCustomComponent client:if={ hydrateMe } />
<YourCustomComponent client:if={ dontHydrateMe } />
In that example, the first instance of <YourCustomComponent />
will be hydrated, while the second instance won’t be.
Conclusion
I found the documentation surrounding custom client:*
directives a bit lacking, but once I had a look at the source code and tinkered a bit, I was surprised at how easy it was to add a custom directive and make it do exactly what I wanted. The documentation made it seem like you’d need to attach some sort of event listener for everything to be set up correctly, but in the end that’s not what the default directives do. It seems to be more of an example when you might want to hydrate a component after a user action.
I wish I had looked at the source sooner, because I played around with all sorts of different listeners, especially since the project I added it to uses route transitions. The directive always kept firing only after the page with the component to be hydrated was replaced by the new one. This just proves to me once again how important it is to be able to look at the source code of something to fully understand it.
I hope this little tutorial could be useful for you! Feel free to tell me if I missed something on Mastodon, and thank you for reading!