Queries
createQuery is the read side of Elur Query. It fetches data, caches it,
and exposes reactive signals for status, data, and error.
# createQuery<T, P>(key, asyncFn, options?)
import { createQuery } from "@elurjs/query";
const q = createQuery(
"posts/list",
() => fetch("/api/posts").then((r) => r.json()),
{ staleTime: 30_000 }
);
// Signals
q.status.value; // "pending" | "success" | "error"
q.data.value; // T | undefined
q.error.value; // unknown
// Methods
q.refetch(); // force refetch
q.dispose(); // clean up# Parameters
| Parameter | Type | Description |
|---|---|---|
key |
string |
Unique cache key (use bounded context: "posts/list") |
asyncFn |
(params: P) => Promise<T> |
Fetcher function |
options |
QueryOptions<P, T> |
Configuration |
# QueryResult<T>
| Member | Type | Description |
|---|---|---|
key |
string |
Effective cache key (includes serialized params) |
status |
Signal<"pending" | "success" | "error"> |
Current status |
data |
Signal<T | undefined> |
Cached data |
error |
Signal<unknown> |
Last error |
refetch() |
() => void |
Force refetch — clears cache + in-flight, bypasses single-flight |
dispose() |
() => void |
Remove from registries, stop param tracking |
dispose() is idempotent. Call it on component unmount:
class PostsPage extends ElurComponent {
private q = createQuery("posts", fetchPosts);
override onUnmount() {
this.q.dispose();
}
}After dispose(), in-flight requests are ignored (not cancelled) —
their results are discarded when they resolve. The query stops reacting
to invalidateQueries, param signal changes, and setQueryData sync
notifications.
# Options
| Option | Type | Default | Description |
|---|---|---|---|
staleTime |
number |
0 |
Ms while cached data is considered fresh |
refetchOnMount |
"always" | "stale" | false |
"always" |
When to refetch on mount |
params |
() => P |
— | Reactive params source; reads signals to auto-refetch |
serializeParams |
(params: unknown) => string |
built-in | Custom serializer for params to cache key |
keepPreviousData |
boolean |
false |
Keep old data visible during refetch |
placeholderData |
T | ((prev: T | undefined) => T | undefined) |
— | Fallback data while pending |
# staleTime + refetchOnMount interaction
refetchOnMount |
Cache fresh (staleTime > 0) |
Cache stale | No cache |
|---|---|---|---|
"always" |
No refetch | Refetch | Fetch |
"stale" |
No refetch | Refetch | Fetch |
false |
No refetch | No refetch | Fetch |
With staleTime: 0 (default), data is never considered fresh, so both
"always" and "stale" refetch on every mount. Use staleTime > 0 with
"stale" to skip refetches while data is fresh.
# Reactive params
Pass params to derive the cache key from signals. The query tracks all
signals read inside the function and refetches automatically when they change:
import { signal } from "@elurjs/core";
import { createQuery } from "@elurjs/query";
const search = signal("");
const page = signal(1);
const posts = createQuery(
"posts",
({ q, page }) => fetch(`/api/posts?q=${q}&page=${page}`).then((r) => r.json()),
{
params: () => ({ q: search.value, page: page.value }),
staleTime: 30_000,
}
);
search.value = "elur"; // → refetch with key "posts::{"q":"elur","page":1}"
page.value = 2; // → refetch with key "posts::{"q":"elur","page":2}"# Effective cache key
The effective key is "<baseKey>::<serializedParams>". The built-in serializer:
- Sorts object keys so
{ a, b }and{ b, a }produce the same key - Serializes
Datevia.toISOString()(timezone-safe) - Serializes
MapandSetwith sorted entries - Throws
TypeErroron circular references
# Custom serializer
const q = createQuery(
"users",
({ id }) => fetch(`/api/users/${id}`).then((r) => r.json()),
{
params: () => ({ id: "abc" }),
serializeParams: (p) => JSON.stringify(p),
}
);
// Use the same serializer for imperative cache access:
setQueryData("users", data, { params: { id: "abc" }, serializeParams: JSON.stringify });
getQueryData("users", { params: { id: "abc" }, serializeParams: JSON.stringify });# Race condition safety
In-flight responses for stale params are ignored. If page changes from 1
to 2 while the page=1 request is still pending, the page=1 response is
discarded.
# Batched param updates
When multiple param signals change together, wrap them in batch() from
@elurjs/core to avoid intermediate refetches. The effect tracks all
signal reads and only fires once after the batch completes:
import { signal, batch } from "@elurjs/core";
const a = signal(1);
const b = signal(2);
const q = createQuery(
"calc",
({ sum }) => fetch(`/api?sum=${sum}`).then((r) => r.json()),
{ params: () => ({ sum: a.value + b.value }) }
);
// Without batch: two refetches (a=3 triggers one, b=1 triggers another)
// With batch: one refetch with the final combined params
batch(() => {
a.value = 3;
b.value = 1;
});
// → single refetch with { sum: 4 }# staleTime: Infinity
Pass staleTime: Infinity to mark cached data as permanently fresh. This
is useful for data that never changes or for implementing "fetch once,
cache forever" patterns:
const q = createQuery("config", fetchConfig, {
staleTime: Infinity,
refetchOnMount: "stale", // never refetch on mount since data is always "fresh"
});# keepPreviousData and placeholderData
# keepPreviousData: true
Old data stays visible while a new fetch is in progress. Eliminates UI flicker during param changes:
const q = createQuery(
"posts",
({ page }) => fetch(`/api/posts?page=${page}`).then((r) => r.json()),
{
params: () => ({ page: page.value }),
keepPreviousData: true,
}
);# placeholderData
Shows fallback data while no cached data exists:
// Static placeholder
const q = createQuery("posts", fetchPosts, { placeholderData: [] });
// Function placeholder (receives previous data)
const q = createQuery(
"posts",
({ page }) => fetch(`/api/posts?page=${page}`).then((r) => r.json()),
{
params: () => ({ page: page.value }),
placeholderData: (prev) => prev ? [...prev] : [],
}
);When both are set, keepPreviousData takes priority if previous data exists;
otherwise placeholderData applies.
# Single-flight deduplication
When two or more components mount the same query key simultaneously with an empty cache, only one fetch is fired. All subscribers share the same in-flight promise:
const q1 = createQuery("users", () => fetch("/api/users").then((r) => r.json()));
const q2 = createQuery("users", () => fetch("/api/users").then((r) => r.json()));
// → only 1 fetch, both q1.data and q2.data resolve togetherrefetch() bypasses single-flight — it deletes the in-flight entry and
starts a new fetch immediately, even if a request for the same key is
already in progress.
# refetch() and data visibility
refetch() deletes the cache entry and in-flight promise, then starts a
new fetch. The data signal is not cleared immediately — old data
stays visible until the new fetch resolves:
- From
success: data remains visible during the refetch (flicker-free by default, no need forkeepPreviousData). - From
error: the error remains visible during the refetch until the new fetch resolves. - From
pending: data is cleared (or placeholder/keepPreviousDataapplies) since the query is already in a pending state.
# Fetch errors do not clear data
When a fetch fails, only error and status are updated. The data
signal retains its previous value:
const q = createQuery("users", fetchUsers);
// ... fetch succeeds, q.data.value = [user1, user2]
q.refetch(); // network fails
// q.status.value === "error"
// q.error.value === Error(...)
// q.data.value === [user1, user2] ← still thereThis lets you show stale data alongside an error indicator, which is useful for transient network failures.
# Synchronous cache hits
When a query mounts and cache exists for its key, status is set to
"success" and data is set to the cached value synchronously —
there is no "pending" state. The freshness check then decides whether
to trigger a background refetch:
setQueryData("users", userData); // populate cache
const q = createQuery("users", fetchUsers, { refetchOnMount: false });
// q.status.value === "success" immediately (synchronous)
// q.data.value === userData# Query keys
Keys are strings, not arrays. Use bounded-context naming to avoid collisions:
createQuery("posts/list", ...); // ✅ descriptive
createQuery("posts/detail:42", ...); // ✅ includes id
createQuery("data", ...); // ❌ too genericWhen using params, the effective key is "baseKey::{serializedParams}":
createQuery("posts", fn, { params: () => ({ page: 1 }) });
// effective key: "posts::{"page":1}"invalidateQueries("posts") clears all param variants ("posts::{"page":1}",
"posts::{"page":2}", etc.).
# GC behavior
Cache entries with zero subscribers are garbage-collected after
setQueryCacheTime ms (default 5 minutes). The GC timer runs every 60
seconds and stops when the cache is empty.
The cache is global, not per-instance. Multiple createQuery calls
with the same key share the same cache entry. When one query writes data,
all other active queries with the same key receive it via sync notification.
If you forget to call dispose(), the query's registry entries are
cleaned up automatically via FinalizationRegistry when the query object
is garbage-collected. However, relying on GC is not recommended — the
timing is unpredictable and param signal tracking (via effect) will
continue until GC runs. Always call dispose() explicitly.