Skip to content

Query

@ailuracode/alpine-query

Store-agnostic async data layer inspired by TanStack Query. Cache remote data, refetch when stale, invalidate after mutations.

The cache engine has no store dependency. Pick an adapter plugin for Alpine.js reactivity:

Package Store runtime Alpine integration
@ailuracode/alpine-query-kit Nanostores Recommended@nanostores/alpine (x-nano, $nano)
@ailuracode/alpine-query-adapter-alpine Native Alpine.reactive Zero extra store deps
@ailuracode/alpine-query-adapter-zustand Zustand vanilla Manual bridge (no official zustand-alpine)

Install

Terminal window
pnpm add @ailuracode/alpine-query @ailuracode/alpine-query-kit alpinejs nanostores @nanostores/alpine

Quick start

import Alpine from "alpinejs";
import query from "@ailuracode/alpine-query";
import {
createAlpineNanostoresAdapter,
NanoStores,
} from "@ailuracode/alpine-query-kit";
Alpine.plugin(NanoStores);
Alpine.plugin(query({ adapter: createAlpineNanostoresAdapter }));
Alpine.start();

Pass the adapter to query(), then it registers $store.query.

Framework-agnostic client

import { createQueryClient, vanillaQueryAdapter } from "@ailuracode/alpine-query";
import { nanostoresQueryAdapter } from "@ailuracode/alpine-query-kit";
// Default: zero-dependency vanilla adapter
const vanilla = createQueryClient();
// Recommended outside Alpine: Nanostores adapter
const query = createQueryClient({ adapter: nanostoresQueryAdapter });

Custom adapter

Implement QueryStateAdapter to connect any reactive store to the query cache. The engine calls your adapter whenever a query or mutation entry is created; you own how that state is stored and how updates propagate.

Set name to a short human-readable label — query devtools show it in the panel title (Alpine Query · Nanostores).

Interface

import type {
MutationStateHandle,
QueryStateAdapter,
QueryStateHandle,
} from "@ailuracode/alpine-query";
// createQueryState → QueryStateHandle
// name — display label for devtools (required)
// get() — snapshot: { data, error, status, fetchStatus, dataUpdatedAt, errorUpdatedAt }
// patch() — apply partial updates from the cache engine
// listen() — subscribe to record changes; return unsubscribe
// state — reactive QueryState<TData> (getters + refetch + isLoading, isSuccess, …)
// createMutationState → MutationStateHandle
// same shape, record fields: { data, error, status }
// state includes mutate / reset plus isPending, isSuccess, …

Helpers exported by the core

Export Use when
createQueryStateView(getRecord, staleTime, refetch) Build QueryState getters + flags from a plain record
createMutationStateView(getRecord, handlers) Build MutationState getters + flags from a plain record
attachQueryFlags(state, staleTime) Attach boolean getters to an existing reactive query object
attachMutationFlags(state) Attach boolean getters to an existing reactive mutation object
createAlpineBridgedAdapter(Alpine, base) Sync any store-backed adapter into Alpine.reactive
vanillaQueryAdapter Reference implementation (zero dependencies)

Store-backed adapter (sketch)

import {
createMutationStateView,
createQueryStateView,
type QueryStateAdapter,
} from "@ailuracode/alpine-query";
export const myStoreAdapter: QueryStateAdapter = {
name: "My Store",
createQueryState(initial, staleTime, refetch) {
const record = { ...initial };
const state = createQueryStateView(() => record, staleTime, refetch);
const listeners = new Set();
return {
state,
get: () => record,
patch: (patch) => {
Object.assign(record, patch);
for (const listener of listeners) listener(record);
},
listen: (listener) => {
listeners.add(listener);
listener(record);
return () => listeners.delete(listener);
},
};
},
createMutationState(handlers) {
const record = { data: undefined, error: null, status: "idle" };
const state = createMutationStateView(() => record, handlers);
const listeners = new Set();
return {
state,
get: () => record,
patch: (patch) => {
Object.assign(record, patch);
for (const listener of listeners) listener(record);
},
listen: (listener) => {
listeners.add(listener);
listener(record);
return () => listeners.delete(listener);
},
};
},
};

Replace the manual Set with your store’s subscribe / listen API when available. See query-adapter-zustand and query-kit.

Register as an Alpine plugin

import query, { createAlpineBridgedAdapter } from "@ailuracode/alpine-query";
import { myStoreAdapter } from "./my-store-adapter.js";
Alpine.plugin(
query({ adapter: (Alpine) => createAlpineBridgedAdapter(Alpine, myStoreAdapter) })
);
// Or pass the adapter directly if it already uses Alpine.reactive
Alpine.plugin(query({ adapter: myAlpineNativeAdapter }));

Headless usage

import { createQueryClient } from "@ailuracode/alpine-query";
import { myStoreAdapter } from "./my-store-adapter.js";
const query = createQueryClient({ adapter: myStoreAdapter });
const todos = query.observe(["todos"], fetchTodos);

Reference implementations

Adapter File
Vanilla (minimal) src/adapters/vanilla.ts
Alpine.reactive query-adapter-alpine
Nanostores query-kit
Zustand query-adapter-zustand

Full guide: docs/query.md — Custom adapter.

Custom Alpine plugin (quick)

import query, { vanillaQueryAdapter } from "@ailuracode/alpine-query";
Alpine.plugin(query({ adapter: vanillaQueryAdapter }));

Queries

Use observe() inside x-data for component-scoped subscriptions (similar to useQuery):

<div
x-data="{
todos: $store.query.observe(['todos'], () => typedFetch('/api/todos')),
}"
>
<p x-show="todos.isLoading">Loading…</p>
<p x-show="todos.isError" x-text="todos.error?.message"></p>
<ul x-show="todos.isSuccess">
<template x-for="todo in todos.data" :key="todo.id">
<li x-text="todo.title"></li>
</template>
</ul>
<button type="button" @click="todos.refetch()">Refresh</button>
</div>

Do not spread the result of observe() — boolean getters such as isLoading and isSuccess are lost when spread.

Import typedFetch and pass an explicit generic when you want stronger inference:

import { typedFetch } from "@ailuracode/alpine-query";
/** @type {() => Promise<{ id: number; title: string }[]>} */
const fetchTodos = () => typedFetch("/api/todos");

Call destroy() when the subscription is no longer needed so unused cache entries can be garbage-collected.

When you create a standalone client with createQueryClient(), call client.destroy() during teardown (tests, HMR, or app shutdown) to remove global focus/visibility listeners and release adapter handles. The Alpine plugin wires this through Alpine.cleanup() automatically.

API

Core exports

Export Description
query({ adapter }) Alpine plugin — pass adapter, registers $store.query
createQueryClient() Store-agnostic client (adapter defaults to vanilla)
createQueryPlugin(adapter) Lower-level registration (prefer query({ adapter }))
createAlpineBridgedAdapter(Alpine, base) Bridge any adapter into Alpine.reactive
QueryStateAdapter Pluggable adapter interface
vanillaQueryAdapter Built-in zero-dep adapter
typedFetch<T>(input, init?) Typed JSON fetch helper for queryFn callbacks
HttpError Thrown when typedFetch receives a non-OK response

See adapter plugin READMEs for Alpine setup with Nanostores, Zustand, or native Alpine.reactive.

$store.query

Method Description
observe(key, queryFn, options?) Subscribe + fetch; returns reactive state with destroy()
fetch(key, queryFn, options?) Fetch without lifecycle subscription
get(key) Read cached query state
prefetch(key, queryFn, options?) Warm cache in the background
invalidate(key?) Mark stale and refetch active queries
remove(key?) Drop cache entries
setData(key, data | updater) Update cached data
cancel(key) Cancel in-flight fetch
reset() Clear entire cache
destroy() Tear down global listeners, timers, and adapter handles (idempotent)
mutate(options) Create a mutation helper

TypeScript

/// <reference types="@ailuracode/alpine-query/global" />

Devtools

Use @ailuracode/alpine-query-kit.

Concepts

Query keys

Arrays identify cached entries. Use stable, serializable values:

['todos']
['todos', todoId]
['users', userId, 'posts']

observe() vs fetch()

  • observe() — for x-data. Subscribes to the cache entry; call destroy() when the subscription is no longer needed so entries can be garbage-collected.
  • fetch() — imperative fetch without holding a subscription.

Stale time and garbage collection

  • staleTime — how long cached data is treated as fresh.
  • gcTime — how long unused entries stay in memory after all observers disconnect.

Mutations

$store.query.mutate() returns a reactive mutation object with mutate(), reset(), and status getters (isPending, isSuccess, …). Use invalidate() in onSuccess to refresh related queries.

Pagination

Use the page number in the query key so each page is cached independently. Call destroy() on the previous observe() result when changing pages so unused entries can be garbage-collected.

License

MIT