When you need interactivity, use custom elements (web components) with native JavaScript. Do not use React, Vue, Svelte, jQuery, or other frameworks.
See the Astro documentation on custom elements for more details.
Good (Custom Element):
<counter-button>
<button>Count: <span>0</span></button>
</counter-button>
<script>
class CounterButton extends HTMLElement {
private count = 0
connectedCallback() {
const span = this.querySelector('span')
this.querySelector('button')?.addEventListener('click', () => {
this.count++
if (span) span.textContent = String(this.count)
})
}
}
customElements.define('counter-button', CounterButton)
</script>
Avoid (React in Astro):
---
import Counter from '../components/Counter.tsx'
---
<!-- Requires @astrojs/react integration, ships React runtime -->
<Counter client:load />
// Counter.tsx - separate file needed
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
Avoid (Pure JavaScript with global state):
<button id="counter-btn">Count: 0</button>
<script>
let count = 0
const btn = document.getElementById('counter-btn')
btn?.addEventListener('click', () => {
count++
btn.textContent = `Count: ${count}`
})
</script>
Avoid (jQuery-style with querySelector):
<button class="js-counter">Count: 0</button>
<script>
let count = 0
document.querySelector('.js-counter')?.addEventListener('click', (e) => {
count++
;(e.target as HTMLElement).textContent = `Count: ${count}`
})
</script>
counter-button, copy-link)this, not document