How to Work With Event Listeners in Astro
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:
<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>
- 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:
<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>
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 tonone
. - 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 betweeninherit
andnone
. - Line 22: Finally, to switch the
toggled
state, we can assign it to its opposite value. If it'strue
, it'll be switched tofalse
; otherwise, totrue
.
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:
<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>
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
withtarget.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:
<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>
- 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.
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:
<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>
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:
<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:
---
const data = 'Hello from the server!'
---
<script define:vars={{ data }}>
console.log(data)
</script>
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.
---
// 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>
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:
---
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>
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:
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 theis: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!
Rocket Launch Your Career
Speed up your learning progress with our mentorship program. Join as a mentee to unlock the full potential of Webtips and get a personalized learning experience by experts to master the following frontend technologies: