π° Simple Query
A simple library to query the DOM from your Astro components.
<RootElement> <button data-target="btn">Click me</button></RootElement>
<script> RootElement.ready(($) => { $('btn').addEventListener('click', () => { console.log("It's like JQuery but not!"); }); });</script>Migration from an older version
If you are using an older version of Simple Query, check the GitHub releases for migration instructions.
Installation
Simple Query is an Astro integration. We recommend installing with the astro add CLI:
astro add @simplestack/queryTo install this integration manually, follow the manual installation instructions
Type checking
Simple Query will automatically set up necessary types when the integration is applied. However, you may need to update your tsconfig.json to include these types in your project.
If your tsconfig.json has an "include" array, add your astro.config.mjs or astro.config.ts file (whichever is applicable). This ensures any types added by the integration are included by type checkers like astro check:
{ "extends": "astro/tsconfigs/strict", "include": ["src", "astro.config.mjs"],}Getting started
To get started, apply the global <RootElement> wrapper around your Astro component markup:
<RootElement> <button>Click me</button></RootElement>Next, apply the data-target attribute to the element you want to target. Simple Query will automatically scope the value to prevent conflicts with other components in your project:
<RootElement> <button data-target="btn">Click me</button> <!--data-target="btn-4SzN_OBB"--></RootElement>Now create a <script> tag, and use the RootElement.ready() wrapper to define your client script. .ready() provides the $() function to select elements based on their data-target attribute. This returns a web-standard HTMLElement you can use to, say, add an event listener with .addEventListener():
<RootElement> <button data-target="btn">Click me</button></RootElement>
<script> RootElement.ready(($) => { $('btn').addEventListener('click', () => { console.log('Hello from the client!') }) })</script>Selecting elements
The $() function provided by RootElement.ready() will find the first match based on data-target, and throw if no match is found.
The result will be a standard HTMLElement by default. To narrow this type to a particular element, pass a type generic to the $() function:
<RootElement> <input type="email" data-target="email" /></RootElement>
<script>RootElement.ready(($) => { $<HTMLInputElement>('email').value = "";})</script>$.optional() selector
$() throws when no matching element is found. To handle undefined values, use the $.optional() function:
---const promoActive = Astro.url.searchParams.has('promo');---
<RootElement> {promoActive && <p data-target="banner">Buy my thing</p>}</RootElement>
<script> RootElement.ready(($) => { $.optional('banner')?.addEventListener('mouseover', () => { console.log("They're about to buy it omg"); }); });</script>$.all() selector
You may want to select multiple targets with the same name. Use the $.all() function to query for an array of results:
---const links = ["wtw.dev", "bholmes.dev"];---
<RootElement> {links.map(link => ( <a href={link} data-target="link">{link}</a> ))}</RootElement>
<script> RootElement.ready(($) => { $.all('link').forEach(linkElement => { /* ... */ }); });</script>$.self selector
To select the RootElement itself, use the $.self value:
<RootElement></RootElement>
<script> RootElement.ready(($) => { $.self.toggleAttribute('data-ready'); })</script>Handling client state
When adding client interactivity, youβll often need to track βstate.β This could be count value for a button counter, an open boolean for a dropdown, and so on.
Using data attributes
The simplest place to store state values is on an element itself. For example, you may track the open state on a dropdown by toggling a data attribute. Call .toggleAttribute() on the dropdown when a button is clicked, and adjust the dropdownβs visibility style from invisible to visible based on the value:
<RootElement> <button data-target="toggle">Toggle</button> <ul data-target="drawer"> <li>Item 1</li> </ul></RootElement>
<script> RootElement.ready(($) => { $('toggle').addEventListener('click', () => { $('drawer').toggleAttribute('data-open'); }) })</script>
<style>ul [data-open] { visibility: visible;}ul { visibility: invisible;}</style><RootElement> <button data-target="toggle">Toggle</button> <ul data-target="drawer" class="data-[open]:visible invisible"> <li>Item 1</li> </ul></RootElement>
<script> RootElement.ready(($) => { $('toggle').addEventListener('click', () => { $('drawer').toggleAttribute('data-open'); }) })</script>Using signals with signal-polyfill
Overtime, you may find it difficult to keep elements on the page in-sync with the state you are tracking. You may need to update multiple elements based on a count, an open state, etc.
Browsers are considering a new standard to address this problem: Signals. Simple Query is built to support an early version of this proposal via the signal-polyfill package.
First install signal-polyfill into your project:
npm install signal-polyfillpnpm add signal-polyfillThen, create your first state variable by importing Signal from signal-polyfill and calling the new Signal.State() constructor:
<script> import { Signal } from 'signal-polyfill';
RootElement.ready(($) => { const count = new Signal.State(0); })</script>Update this value using .set(), and retrieve the current value using .get(). This example increments a counter when the btn element is pressed:
<RootElement> <button data-target="btn">0</button></RootElement>
<script> import { Signal } from 'signal-polyfill';
RootElement.ready(($) => { const count = new Signal.State(0);
$('btn').addEventListener('click', () => { count.set(count.get() + 1); }) })</script>To update the document, RootElement.ready() provides an effect() function. This defines a block of code that should rerun whenever a state variable inside that block changes.
Access the effect() function from the ctx object. This passed as the second argument to RootElement.ready().
This example will update a buttonβs textContent whenever count changes:
<RootElement> <button data-target="btn">0</button></RootElement>
<script> import { Signal } from 'signal-polyfill';
RootElement.ready(($, ctx) => { const count = new Signal.State(0);
$('btn').addEventListener('click', () => { count.set(count.get() + 1); })
ctx.effect(() => { $('btn').textContent = count.get().toString(); }) })</script>Passing server data
You may need to pass information from your Astro component to the client. For boolean values, the simplest method is via data attributes. This example sets the initial state of a dropdown by setting data-open from the template:
<RootElement> <button data-target="toggle">Toggle</button> <ul data-open data-target="drawer"> <li>Item 1</li> </ul></RootElement>
<script> RootElement.ready(($) => { $('toggle').addEventListener('click', () => { $('drawer').toggleAttribute('data-open'); }) })</script>Still, you may need to pass other values including numbers, arrays, or objects. This is common when using Signals for state management.
For this, <RootElement> accepts a data property. This supports any JSON-serializable value:
---const initialCount = 0;---
<RootElement data={{ initialCount }}> <button data-target="btn">Click me</button></RootElement>Then, retrieve this value from the client using the data object. This is available from the ctx object passed as the second argument by RootElement.ready().
---const initialCount = 0;---
<RootElement data={{ initialCount }}> <button data-target="btn">Click me</button></RootElement>
<script> import { Signal } from 'signal-polyfill';
RootElement.ready(($, ctx) => { const { initialCount } = ctx.data; const count = new Signal.State(count);
$('btn').addEventListener('click', () => { count.set(count.get() + 1); }) })</script>RootElement also accepts a type argument to enforce the type of data:
<script> import { Signal } from 'signal-polyfill';
RootElement.ready<{ initialCount: number }>(($, ctx) => { // ... })</script>Passing data-target as a component prop
You can pass a data-target value as a prop to nested components as well. This allows you to target elements defined deeper in the component tree that depend on the same client state.
This example defines a Button component that accepts a target prop applied to the data-target attribute:
---type Props = { target: string;}
const { target } = Astro.props;---
<button data-target={target}> <slot /></button>To pass a scoped target value, you will need to wrap your value using the scope() function from the simple:scope module. This applies the same scoping Simple Query applies by default to data-target attributes:
---import Button from "../components/Button.astro";import { scope } from "simple:scope";---
<RootElement> <Button target={scope("btn")}> Click me </Button></RootElement>
<script> RootElement.ready(($) => { $('btn').addEventListener('click', () => { /* ... */ }); })</script>Handling event cleanup
You may have logic in your RootElement.ready() function that should be cleaned up on route change. This includes:
fetch()callsdocumentevent listeners- intersection and mutation observers
Clean up fetch and document callbacks
The fetch() and document.addEventListener() functions accept an AbortSignal to cancel events. Simple Query provides an abortSignal via the ctx property, which will trigger whenever the RootElement is removed from the page.
Apply ctx.abortSignal to any fetch() or document.addEventListener() calls in your client script using the signal property:
<script> RootElement.ready(async ($, ctx) => { const recommendedPosts = await fetch('/api/posts', { signal: ctx.abortSignal, }).then(res => res.json());
document.addEventListener('scroll', () => { const articleRect = $('article').getBoundingClientRect(); const articleHeight = $('article').offsetTop + articleRect.height; $('progress').value = Math.min((window.scrollY + window.innerHeight) / articleHeight, 1); }, { signal: ctx.abortSignal }); })</script>Clean up observers and other events
You may listen to events that do not accept an AbortSignal. This includes intersection and mutation observers. For these cases, return a callback function from RootElement.ready() with any cleanup logic you need to run on navigation:
<script> RootElement.ready(($) => { const observer = new IntersectionObserver( ([entry]) => { /* ... */ }, ); observer.observe($("element"));
return () => { observer.disconnect(); } })</script>