Skip to content

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:

Terminal window
astro add simple-stack-query

To 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:

tsconfig.json
{
"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>

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:

Terminal window
npm install signal-polyfill

Then, 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:

src/components/Button.astro
---
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:

src/pages/index.astro
---
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() calls
  • document event 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>