How to Work With Event Listeners in Astro

How to Work With Event Listeners in Astro

Writing JavaScript in Astro
Ferenc Almasi β€’ 2023 November 30 β€’ Read time 11 min read
Learn how to write JavaScript in Astro components to make them interactive.

Creating event handlers in JavaScript frameworks is a core concept that can turn static components into interactive widgets. This is usually done through component state that can be stored in different ways, depending on the framework. For example, using useState in React or reactive variables in Svelte.

As Astro is a static site generator, it doesn't have client-side dynamic behavior; therefore, it works fundamentally differently from popular JavaScript frameworks when it comes to reactivity: we have to rely on vanilla JavaScript. In this tutorial, we'll take a look at how to handle client-side JavaScript code in an Astro component and how to turn them into interactive components through event listeners.


Script Behavior

As discussed in a previous tutorial, Astro supports the use of script tags inside components. However, in Astro, they have some extra behavior compared to regular script tags. They support the following behavior:

Copied to clipboard! Playground
<button>Count: 0</button>

<!-- Code will be automatically bundled by Astro -->
<script>
    // Imports are available
    import { initialCount } from '../state.ts'
  
    // Supports TypeScript
    const button = document.querySelector('button') as HTMLButtonElement
    let count = initialCount

    button.addEventListener('click', () => {
        count++
	button.innerText = `Count: ${count}`
    })
</script>
Example script tag in an Astro component
  • Bundling: Code is automatically bundled and minified by Astro on build.
  • Imports: JavaScript and TypeScript modules can be imported from other files.
  • TypeScript: Supports TypeScript out of the box, but scripts can also be written using vanilla JavaScript.
  • SSR: Just like with CSS variables, scripts can also accept JavaScript variables from server-side code (from the component script).

This behavior also allows Astro to only include the same script once, regardless of component usage. This means we can keep the component-related scripts coupled with the component itself without having to worry about including it more than necessary.


Click Events

As shown in the above example, Astro components don't have state variables. Instead, we need to rely on vanilla JavaScript. To better understand how to add interactive functionality, take a look at the following example that utilizes a native click event listener:

Copied to clipboard! Playground
<div>
    <slot />
</div>

<button class="toggle-button">Toggle</button>

<style>
    div {
        display: none;
    }
</style>

<script>
    const button = document.querySelector('.toggle-button')
    const container = button.previousElementSibling

    let toggled = false

    button.addEventListener('click', () => {
        container.style.display = toggled ? 'inherit' : 'none'

        toggled = !toggled
    })
</script>
Toggle.astro
Handling click events in Astro

This component can be used to toggle the visibility of an element on and off:

  • Lines 7-11: First, we initially hide the component by setting its container's display property to none.
  • Lines 14-15: Using query selectors, we grab the button from JavaScript. Note that we must use unique names for selectors to avoid selecting the wrong element on the page. To ensure we always select the correct container, we can use the previousElementSibling selector, which will always select the element before the button.
  • Line 17: We set up a toggled state that can keep track of the visibility of the element.
  • Line 20: We can toggle the visibility on and off by selecting the CSS display property and switching its value between inherit and none.
  • Line 22: Finally, to switch the toggled state, we can assign it to its opposite value. If it's true, it'll be switched to false; otherwise, to true.

As the script in this component selects a single button, we'll run into a problem when we try using the same component on the same page multiple times. It'll only work for the first button with the class .toggle-button, as the other buttons won't receive the event listener.

Multiple Event Listeners

There are two ways to fix the issue above. Either we need to use event delegation, or we need to attach an event listener to each button. Let's see how to solve this using both solutions. To use event delegation, we can rewrite the functionality as follows:

Copied to clipboard! Playground
<script>
    document.addEventListener('click', event => {
        const target = event.target

        if (event.target.className.includes('toggle-button')) {
            const container = target.previousElementSibling
            const display = container.style.display

            container.style.display = display === 'none'
                ? 'inherit'
                : 'none'
        }
    })
</script>
Toggle.astro
Event delegation in Astro

We removed the selectors and instead, attached an event listener to the document to listen for all click events:

  • Line 5: Inside the event listener, we can check if the event.target (the element receiving the click event) has a .toggle-button class name. This identifies the button, meaning if the clicked element includes the class name, the user clicked on a toggle button.
  • Line 6: We need to replace button.previousElementSibling with target.previousElementSibling to reference the correct element.
  • Lines 7-11: We also need to remove the toggled state as we need to ensure each button has its own state. We can use a ternary operator to check for the current display status.

For improved JavaScript performance, it's recommended to attach the event listener as close to the receiving element as possible. For example, if toggle elements are always created inside an article element, we can use the article instead of the document.

To achieve the same functionality using multiple event listeners, we can introduce a loop to create as many listeners as there are buttons:

Copied to clipboard! Playground
<script>
    const buttons = document.querySelectorAll('.toggle-button')

    buttons.forEach(button => {
        let toggled = false

        button.addEventListener('click', event => {
            const container = button.previousElementSibling

            container.style.display = toggled ? 'inherit' : 'none'

            toggled = !toggled
        })
    })
</script>
Toggle.astro
Using multiple event listeners in Astro
  • Line 2: To select all relevant buttons, we can use querySelectorAll which returns all elements matching the passed selector.
  • Line 4: Using a forEach loop, we can loop through the buttons to create individual event listeners for them.
  • Line 5: We need to move the toggled state inside the loop to create a new state variable for each button.

The rest of the functionality inside the event listeners stays the same. So which one should we use? As always, it depends on the use case.

Using event delegation offers better performance as we only need a single event listener. However, if there's no common container, and you can only attach a click event listener on the document itself, then using a loop might be a better strategy to avoid capturing all click events.

Looking to improve your skills? Master Astro + SEO from start to finish.
info Remove ads

Handling Events on Dynamic Elements

When it comes to handling events on dynamic elements, we have to resort to event delegation. As these elements don't exist on the page when we attach event listeners, we cannot use a loop. Instead, we need to think in terms of event delegation. For example:

Copied to clipboard! Playground
<main>
    <button>Add</button>
    <input />
</main>

<script>
    const button = document.querySelector('button')
    const container = document.querySelector('main')

    button.addEventListener('click', () => {
        const input = document.createElement('input')

	container.appendChild(input)
    })

    container.addEventListener('keyup', event => {
        if (event.target.nodeName === 'INPUT') {
	    console.log('input changed to', event.target.value)
	}
    })
</script>
Use event delegation for dynamic elements

Here we have a button that adds new input elements to the page. As these elements are dynamically created, we either need to attach the event listeners to them dynamically too, or we need to use event delegation, which is a simpler solution.

This is what we've achieved on lines 16-20. Using nodeName, we can get the type of element where the onkeyup event occurs, and execute code accordingly.


Inline Scripts

The above examples will all receive Astro's extra optimization steps during bundling. However, there could be cases where we want to leave script tags as-is without any modification. These are called inline scripts. To avoid script tags from being bundled, we can use the is:inline directive:

Copied to clipboard!
<script is:inline src="https://code.jquery.com/jquery.min.js" />

This is useful for third-party scripts that should not be bundled by Astro or scripts that should be included every time a component is called. Scripts are also treated as inline when their type attribute is set to module. Opting out of bundling also means:

  • The script will be rendered as-is.
  • Imports are not resolved and will not work.
  • The script will be included every time the component is used.

Using Server-side Variables

It's also possible to pass server-side variables to client-side scripts in Astro components through the define:vars directive. This is the same directive that can convert server-side variables to CSS variables. Take the following as an example:

Copied to clipboard! Playground
---
const data = 'Hello from the server!'
---

<script define:vars={{ data }}>
    console.log(data)
</script>
Passing server-side variables to client-side scripts in Astro

Using the define:vars directive will disable the default script bundling behaviors.

This is useful if we need to provide the client with data that is otherwise only available on the server. For example, we can achieve client-side sorting and filtering by sending the list of items to the client and storing them based on the state.

Server-side variables with imports

Because script tags with the define:vars directive behave as inline scripts, we cannot use import statements. This is because the server-side variable being passed can be dynamic in nature, meaning Astro needs to create a script tag for each value.

Copied to clipboard! Playground
---
// Page ID can be 0, 999, or anything in between
const { pageId } = Astro.props
---

<!-- Meaning a new `script` tag is needed for each value -->
<script define:vars={{ pageId }}>
    console.log(pageId)
</script>
Script tags cannot be bundled with server-side variables

To get around this limitation and use import statements, we can create two separate script tags in the component: one where we define server-side variables, and one where we define the actual logic:

Copied to clipboard! Playground
---
const data = [{ ... }, { ... }]
---

<script define:vars={{ data }}>
    window.app = window.app || {}
    window.app.data = data
</script>

<script>
    // Imports are available here
    import { sort } from '../../utils'

    sort(app.data)
</script>
Using imports with define:vars

When using define:vars, we can create global variables by attaching them to the window object under a unique namespace. This way, we can access them elsewhere. Now we can reach the server-side data in another script where we can use imports.

Always create a unique namespace under the window object when using global variables to avoid naming collisions.


Summary

As we can see, we have just as many methods to handle scripts in Astro as we have for styling components. Because Astro components rely on vanilla JavaScript, a basic understanding of core concepts is necessary for building Astro components. If you're new to JavaScript, be sure to check out our JavaScript roadmap:

Master JavaScript

To summarize, here's a list of bullet points on when to use which type of script tag:

  • script without attributes: Use as the default options, and where imports and bundling are needed. Utilize loops or event delegation for handling events on the same component multiple times.
  • is:inline: Use for third-party scripts, and where you'd like to avoid bundling.
  • define:vars: Use for cases where you need to access server-side variables in client-side scripts. Note that these behave as if they have the is:inline directive.

Do you have any experience of working with JavaScript in Astro? Leave your thoughts about this approach in the comments below. Thank you for reading through, happy coding!

  • twitter
  • facebook
Did you find this page helpful?
πŸ“š More Webtips
Frontend Course Dashboard
Master the Art of Frontend
  • check Access 100+ interactive lessons
  • check Unlimited access to hundreds of tutorials
  • check Prepare for technical interviews
Become a Pro

Courses

Recommended

This site uses cookies We use cookies to understand visitors and create a better experience for you. By clicking on "Accept", you accept its use. To find out more, please see our privacy policy.