Patterns
Common patterns for using Elur Query in real applications.
# ElurComponent integration
# Query in a component
import { html, ElurComponent } from "@elurjs/core";
import { createQuery } from "@elurjs/query";
class PostsPage extends ElurComponent {
private q = createQuery("posts/list", () => fetch("/api/posts").then((r) => r.json()));
override render() {
return html`
<h1>Posts</h1>
${() => this.q.status.value === "pending" && html`<p>Loading...</p>`}
${() => this.q.status.value === "error" && html`<p>Error: ${() => String(this.q.error.value)}</p>`}
${() => this.q.status.value === "success" && html`
<ul>
${() => this.q.data.value?.map((p) => html`<li>${p.title}</li>`)}
</ul>
`}
`;
}
override onUnmount() {
this.q.dispose();
}
}# Command in a component
class EditProfilePage extends ElurComponent {
private save = createCommand(
"profile/save",
async (data: { name: string }) => {
const res = await fetch("/api/profile", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
{ invalidate: ["profile/current"] }
);
override render() {
return html`
<form @submit=${(e: Event) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
this.save.execute({ name: formData.get("name") as string });
}}>
<input name="name" />
<button type="submit" disabled=${() => this.save.isPending.value}>
${() => this.save.isPending.value ? "Saving..." : "Save"}
</button>
</form>
${() => this.save.isError.value && html`<p>Error: ${() => String(this.save.error.value)}</p>`}
${() => this.save.isSuccess.value && html`<p>Saved!</p>`}
`;
}
override onUnmount() {
this.save.dispose();
}
}# Optimistic update with rollback
Use onMutate to apply optimistic changes and onError to roll back:
import { createCommand, getQueryData, setQueryData } from "@elurjs/query";
type Todo = { id: number; title: string; done: boolean };
const toggleTodo = createCommand(
"todos/toggle",
async (id: number) => {
const res = await fetch(`/api/todos/${id}/toggle`, { method: "PATCH" });
if (!res.ok) throw new Error("Failed");
return res.json();
},
{
onMutate: (id) => {
const previous = getQueryData<Todo[]>("todos/list") ?? [];
setQueryData("todos/list", previous.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
));
return { previous };
},
onError: (_err, _id, ctx) => {
setQueryData("todos/list", ctx?.previous ?? []);
},
invalidate: ["todos/list"],
}
);onMutate return value becomes the context argument of onSuccess,
onError, and onSettled.
# Pagination
# Basic pagination with params
import { signal } from "@elurjs/core";
import { createQuery } from "@elurjs/query";
const page = signal(1);
const posts = createQuery(
"posts/list",
({ page }) => fetch(`/api/posts?page=${page}`).then((r) => r.json()),
{
params: () => ({ page: page.value }),
keepPreviousData: true,
staleTime: 60_000,
}
);
// In template:
// ${() => posts.data.value?.map((p) => html`<li>${p.title}</li>`)}
// <button @click=${() => page.value++}>Next</button># Search + pagination combined
const search = signal("");
const page = signal(1);
const results = createQuery(
"posts/search",
({ q, page }) => fetch(`/api/posts?q=${q}&page=${page}`).then((r) => r.json()),
{
params: () => ({ q: search.value, page: page.value }),
keepPreviousData: true,
placeholderData: (prev) => prev ?? [],
}
);
// Reset to page 1 when search changes
// (handled automatically — new params = new cache key)# Infinite scroll pattern
import { signal } from "@elurjs/core";
import { createQuery, getQueryData, setQueryData } from "@elurjs/query";
const pages = signal<any[][]>([]);
const infinite = createQuery(
"posts/infinite",
async ({ page }) => {
const res = await fetch(`/api/posts?page=${page}`).then((r) => r.json());
const current = getQueryData<any[]>("posts/infinite:all") ?? [];
setQueryData("posts/infinite:all", [...current, ...res.items]);
return res;
},
{
params: () => ({ page: page.value }),
keepPreviousData: true,
}
);# Dependent queries
Query B depends on query A's data. Use a guard signal to prevent fetching until the dependency is ready:
import { signal, computed } from "@elurjs/core";
const userQuery = createQuery("user/current", () => fetch("/api/me").then((r) => r.json()));
// Track whether the user ID is available
const userId = computed(() => userQuery.data.value?.id ?? 0);
const ordersQuery = createQuery(
"orders/list",
({ userId }) => fetch(`/api/users/${userId}/orders`).then((r) => r.json()),
{
params: () => ({ userId: userId.value }),
}
);The dependent query will fetch with userId: 0 until userQuery resolves.
To avoid the initial wasted request, gate the fetcher:
const ordersQuery = createQuery(
"orders/list",
async ({ userId }) => {
if (!userId) return []; // skip fetch until userId is available
return fetch(`/api/users/${userId}/orders`).then((r) => r.json());
},
{
params: () => ({ userId: userId.value }),
}
);# Polling
Use effect + setInterval for polling:
import { effect, signal } from "@elurjs/core";
import { createQuery } from "@elurjs/query";
const notifications = createQuery("notifications", () => fetch("/api/notifications").then((r) => r.json()));
const polling = signal(true);
const dispose = effect(() => {
if (!polling.value) return;
const interval = setInterval(() => notifications.refetch(), 10_000);
return () => clearInterval(interval);
});
// Stop polling: polling.value = false;# Mutation + invalidation flow
const createPost = createCommand(
"posts/create",
async (data: { title: string; body: string }) => {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed");
return res.json();
},
{
invalidate: ["posts/list", "feed", "stats"],
onSuccess: (data) => {
console.log("Created post:", data.id);
},
}
);# Offline-first pattern
import { createCommand, type CommandQueueAdapter } from "@elurjs/query";
class IndexedDBQueueAdapter implements CommandQueueAdapter<any> {
// ... implement with IndexedDB
}
const syncCommand = createCommand(
"data/sync",
async (payload) => {
const res = await fetch("/api/sync", { method: "POST", body: JSON.stringify(payload) });
return res.json();
},
{
mode: "queueOffline",
offline: {
adapter: new IndexedDBQueueAdapter(),
replayOnReconnect: true,
maxReplayAttempts: 5,
onEnqueue: (entry) => console.log("Queued:", entry.id),
onReplaySuccess: (data, entry) => console.log("Replayed:", entry.id),
onReplayError: (err, entry) => console.error("Replay failed:", entry.id, err),
},
}
);
// When online: executes immediately
// When offline: enqueues, replays on reconnect# Testing
import { createQuery, clearQueryCache, setQueryData } from "@elurjs/query";
beforeEach(() => {
clearQueryCache(); // clean slate between tests
});
test("query fetches data", async () => {
const q = createQuery("test", async () => ({ hello: "world" }));
await new Promise((resolve) => setTimeout(resolve, 10)); // wait for fetch
expect(q.data.value).toEqual({ hello: "world" });
q.dispose();
});
test("setQueryData populates cache", () => {
setQueryData("test", { hello: "world" });
const q = createQuery("test", async () => { throw new Error("should not fetch"); }, {
refetchOnMount: false,
});
expect(q.data.value).toEqual({ hello: "world" });
q.dispose();
});