Basic event handling in Astro, not so straightforward!

I love Astro.js and how it allows you to write a simple component-based site without a UI framework. You can treat it as a thin layer on top of HTML, CSS and JavaScript, and it provides so much value with such a minimal overhead. Not having to deal with React, Vue or Svelte is honestly a breath of fresh air sometimes.

However, there will be times where some interactivity is needed on the client, and you will need to handle events. Astro made the smart decision of following standard HTML. This is how you would handle a typical click event according to the docs:

src/components/AlertButton.astro

<button class="alert">Click me!</button>

<script>
	document.querySelector('button.alert').addEventListener('click', () => {
		alert('Button was clicked!');
	});
</script>

Unfortunately, this does not always work as expected. If you enable View Transitions, the event handler will not be attached to the button after you navigate to a page for the second time. This is because Astro will remove the old DOM and replace it with the new one, so the event handler will be lost, your script will not be re-run, and your button will not work.

To deal with this, there are several alternatives, each one with its own caveats. Let’s explore them:

  • astro:page-load

    You could use the astro:page-load event in every <script> tag of your components. However according to my testing this event can be raised significantly later than the original script and way after the page is visible to the user. This causes a noticeable delay after the page is shown where the button will not work, i.e. bad UX.

  • astro:after-swap

    You could use both the original event listener and the astro:after-swap event like this:

    <button class="alert">Click me!</button>
    
    <script>
    	function handleEvents() {
    		document.querySelector('button.alert').addEventListener('click', () => {
    			alert('Button was clicked!');
    		});
    	}
    
    	handleEvents(); // Used for the first navigation
    	document.addEventListener('astro:after-swap', () => {
    		handleEvents(); // Used for subsequent navigations
    	});
    </script>

    This works correctly but is quite cumbersome to do for every component. It’s also not very clean. But what I dislike most about this approach is that if you happen to disable View Transitions, the astro:after-swap event will stop working and you’ll have those events hanging everywhere, which could potentially have been used to setup other completely unrelated stuff that will now break. We can do better.

  • Web Components

    Web Components to the rescue! By using a custom element for each Astro component, you will be able to handle events in a clean and consistent way by simply adding your event listeners in the connectedCallback() lifecycle callback. This is how you would do it:

    <alert-button>
    	<button class="alert">Click me!</button>
    </alert-button>
    
    <script>
    	customElements.define(
    		'alert-button',
    		class extends HTMLElement {
    			connectedCallback() {
    				document.querySelector('button.alert').addEventListener('click', () => {
    					alert('Button was clicked!');
    				});
    			}
    		}
    	);
    </script>

    This works with or without View Transitions, without any duplicate code, and is not Astro specific. The only downside is that it’s not as concise as I would love, and you will need to create and name a custom element (<alert-button> in this case) for every component. But I think it’s worth it because there’s no big downside as opposed to with the other alternatives.

Conclusion

Even if you are not using View Transitions now, both the View Transitions API and Web Components are great standard features that will become more and more widespread. I hope this article helps you support and leverage both standards to appropriately handle onclick and other events in your Astro components.

Do you know a better way? Please share it with me and I’ll update this article. 😊

Published on Jan 15, 2024.