React Hooks
The useQuery hook connects React components to Routier's live query system, automatically subscribing to data changes and managing subscription cleanup.
How It Works
useQuery follows a subscription pattern:
- Setup: Your query function sets up a subscription and provides a callback
- Updates: The callback receives new data as it changes
- Cleanup: When dependencies change or the component unmounts, subscriptions are cleaned up
- State: Returns a discriminated union for safe status checking
type LiveQueryState<T> =
| { status: "pending"; loading: true; error: null; data: undefined }
| { status: "error"; loading: false; error: Error; data: undefined }
| { status: "success"; loading: false; error: null; data: T };The hook uses useEffect internally, re-running your query when dependencies change and calling the cleanup function you return.
Important: When you subscribe to a query inside useQuery, you must return the unsubscribe handler from your callback. The query chain (e.g. .subscribe().where(...).firstOrUndefined(callback)) returns that handler. If you use a block body, explicitly return it so the hook can clean up on unmount or when dependencies change—otherwise you risk subscription leaks and stale updates.
API
function useQuery<T>(
subscribe: (callback: (result: ResultType<T>) => void) => void | (() => void),
deps?: any[]
): LiveQueryState<T>;Parameters:
subscribe- Function that creates your subscription and calls the callback with results. Must return the unsubscribe handler (the return value of the query chain, e.g..subscribe().toArray(callback)) so the hook can clean up.deps- Optional dependency array (works likeuseEffectdependencies)
Returns: A state object with status, loading, error, and data properties
Understanding Subscriptions
With .subscribe() - Live Updates
Calling .subscribe() creates a live query that automatically re-runs when data changes. You must return the unsubscribe handler so useQuery can clean up:
// ✅ Live updates - return the query so useQuery can unsubscribe
const products = useQuery(
(callback) => dataStore.products.subscribe().toArray(callback),
[]
);
// When you add a product, the component automatically updates
await dataStore.products.addAsync({ name: "New Product" });
await dataStore.saveChangesAsync();With a block body, explicitly return the result of the chain:
// ✅ Return the unsubscribe handler (required for cleanup)
const currentUserQuery = useQuery<User | undefined>(
(callback) => {
return dataStore.users
.subscribe()
.where(([u, p]) => u.userRef === p.sub, { sub: user.sub })
.firstOrUndefined(callback);
},
[dataStore, user?.sub, shouldRunUserQueries]
);Use .subscribe() when:
- You want your UI to stay in sync with data changes
- Building reactive, real-time features
- Data is expected to change during the component's lifetime
Without .subscribe() - One-Time Query
Omitting .subscribe() runs the query once when the component mounts:
// ❌ One-time only - never updates
const products = useQuery(
(callback) => dataStore.products.toArray(callback), // No .subscribe()
[]
);
// Adding products won't cause a re-render
await dataStore.products.addAsync({ name: "New Product" });
// Component stays the sameUse without .subscribe() when:
- Fetching static data that won't change
- Performing one-time initialization
- Loading data for a single render
Examples
Basic List Query
Subscribe to an entire collection:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore"; // Your app's context
export function ProductsList() {
const dataStore = useDataStore();
// Subscribe to live query results
const products = useQuery(
(callback) => dataStore.products.subscribe().toArray(callback),
[]
);
// Handle loading state
if (products.status === "pending") {
return <div>Loading...</div>;
}
// Handle error state
if (products.status === "error") {
return <div>Error: {products.error?.message}</div>;
}
// Render data
return (
<ul>
{products.data?.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}Count Query
Get the count of items:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function ProductCount() {
const dataStore = useDataStore();
// Query a count
const countResult = useQuery(
(callback) => dataStore.products.subscribe().count(callback),
[]
);
if (countResult.status === "pending") return <div>Loading...</div>;
if (countResult.status === "error") return <div>Error loading count</div>;
return <div>Total products: {countResult.data}</div>;
}Filtered Query with Dependencies
Search and filter with reactive updates:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
import { useState } from "react";
export function FilteredProducts() {
const dataStore = useDataStore();
const [searchTerm, setSearchTerm] = useState("");
// Re-run query when search term changes
const products = useQuery(
(callback) =>
dataStore.products
.where((p) => p.name.includes(searchTerm))
.subscribe()
.toArray(callback),
[searchTerm] // Re-run when searchTerm changes
);
if (products.status === "pending") return <div>Loading...</div>;
if (products.status === "error") return <div>Error</div>;
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
<ul>
{products.data?.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}Single Item Query
Get one item by ID or condition:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function SingleProduct({ productId }: { productId: string }) {
const dataStore = useDataStore();
// Query a single item by ID
const product = useQuery(
(callback) =>
dataStore.products
.where((p) => p.id === productId)
.subscribe()
.first(callback),
[productId] // Re-run when productId changes
);
if (product.status === "pending") return <div>Loading...</div>;
if (product.status === "error") return <div>Product not found</div>;
return (
<div>
<h1>{product.data?.name}</h1>
<p>{product.data?.description}</p>
</div>
);
}Sorted Results
Apply sorting to your query:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
import { useMemo } from "react";
export function SortedProducts() {
const dataStore = useDataStore();
// Sort products by name
const products = useQuery(
(callback) =>
dataStore.products
.subscribe()
.sort((p) => p.name)
.toArray(callback),
[]
);
if (products.status === "pending") return <div>Loading...</div>;
if (products.status === "error")
return <div>Error: {products.error.message}</div>;
return (
<ul>
{products.data?.map((product) => (
<li key={product.id}>
{product.name} - ${product.price}
</li>
))}
</ul>
);
}Pagination with Dependencies
Use take/skip with reactive filtering:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function RecentOrders({ days }: { days: number }) {
const dataStore = useDataStore();
const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Query with pagination
const orders = useQuery(
(callback) =>
dataStore.orders
.where((o) => o.createdAt >= cutoffDate)
.subscribe()
.sort((o) => o.createdAt)
.take(10)
.toArray(callback),
[cutoffDate, days] // Re-run when days changes
);
if (orders.status === "pending") return <div>Loading recent orders...</div>;
if (orders.status === "error") return <div>Error loading orders</div>;
return (
<div>
<h2>Recent Orders (Last {days} days)</h2>
<ul>
{orders.data?.map((order) => (
<li key={order.id}>
{order.total} - {new Date(order.createdAt).toLocaleDateString()}
</li>
))}
</ul>
</div>
);
}Custom Subscription with Cleanup
For advanced use cases with manual cleanup:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function CustomSubscription() {
const dataStore = useDataStore();
// Custom subscription with explicit cleanup
const data = useQuery((callback) => {
const subscription = dataStore.products.subscribe();
// Set up an onChange handler
const unsubscribe = subscription.onChange(() => {
subscription.toArray(callback);
});
// Initial data fetch
subscription.toArray(callback);
// Return cleanup function
return unsubscribe;
}, []);
if (data.status === "pending") return <div>Loading...</div>;
if (data.status === "error") return <div>Error</div>;
return <div>{data.data?.length} products</div>;
}Multiple Queries in One Component
Run multiple independent queries:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function MultipleQueries() {
const dataStore = useDataStore();
// Multiple independent queries in one component
const products = useQuery(
(callback) => dataStore.products.subscribe().toArray(callback),
[]
);
const categories = useQuery(
(callback) => dataStore.categories.subscribe().toArray(callback),
[]
);
const productCount = useQuery(
(callback) => dataStore.products.subscribe().count(callback),
[]
);
if (products.status === "pending" || categories.status === "pending") {
return <div>Loading...</div>;
}
return (
<div>
<p>Products: {productCount.data}</p>
<ul>
{products.data?.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}One-Time Queries Without Subscription
For static data that doesn't need updates:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function OneTimeQuery() {
const dataStore = useDataStore();
// This query runs ONCE and never updates
// Perfect for static configuration or initial data that won't change
const config = useQuery(
(callback) => dataStore.settings.toArray(callback), // No .subscribe()
[]
);
if (config.status === "pending") return <div>Loading config...</div>;
if (config.status === "error") return <div>Error loading config</div>;
return (
<div>
<h1>App Configuration</h1>
<p>Theme: {config.data?.[0]?.theme}</p>
<p>Language: {config.data?.[0]?.language}</p>
</div>
);
}Suspense with Async Terminals (React 19)
useQuery is an effect-based subscription hook; it returns pending, error, or success state and does not suspend. For React 19 Suspense, pass a stable Promise from a Routier async terminal such as toArrayAsync() to React's use():
import { Suspense, use, useState } from "react";
import { useDataStore } from "../../useDataStore";
type AppDataStore = ReturnType<typeof useDataStore>;
function loadPage(store: AppDataStore, page: number, pageSize: number) {
return store.products
.sort(product => product._id)
.skip((page - 1) * pageSize)
.take(pageSize)
.toArrayAsync();
}
type PagePromise = ReturnType<typeof loadPage>;
function ProductRows({ pagePromise }: { pagePromise: PagePromise }) {
// React shows the nearest Suspense fallback until this Promise resolves.
const products = use(pagePromise);
return products.map(product => (
<div key={product._id}>{product.name}</div>
));
}
export function SuspenseProductsPage() {
const store = useDataStore(); // Keep this instance stable (Context or useMemo).
const [request, setRequest] = useState(() => ({
page: 1,
pageSize: 20,
promise: loadPage(store, 1, 20),
}));
function showPage(page: number, pageSize: number) {
// Create the Promise in the event/state update, not in ProductRows' render.
setRequest({ page, pageSize, promise: loadPage(store, page, pageSize) });
}
return (
<>
<select
value={request.pageSize}
onChange={event => showPage(1, Number(event.target.value))}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<Suspense fallback={<p>Loading page…</p>}>
<ProductRows pagePromise={request.promise} />
</Suspense>
<button
disabled={request.page === 1}
onClick={() => showPage(request.page - 1, request.pageSize)}
>
Previous
</button>
<span>Page {request.page}</span>
<button onClick={() => showPage(request.page + 1, request.pageSize)}>
Next
</button>
</>
);
}Create or cache the Promise outside the component that calls use(). Creating toArrayAsync() directly during that component's render produces a new Promise on every retry. This pattern performs one read for each Promise; it is not a live subscription. See React pagination choices for the behavioral comparison and error-boundary note.
Quick Reference
| Query Type | Pattern | When to Use |
|---|---|---|
| Live Updates | useQuery + .subscribe().toArray(callback) | Data changes, need initial + updates |
| One-Time Fetch | useQuery + .toArray(callback) | Effect-based fetch with explicit status |
| Suspense Read (React 19) | use(toArrayAsync()) with a stable/cached Promise | Boundary-based loading for one-time reads |
Rule: When using .subscribe(), return the query from your callback (e.g. return dataStore.users.subscribe().where(...).firstOrUndefined(callback)) so useQuery can unsubscribe on cleanup.
Examples:
- Products list (changes) → Use
.subscribe()and return the query - App config (static) → No
.subscribe()
Patterns and Best Practices
Accessing Your Data Store
Create your DataStore in a simple custom hook:
// hooks/useDataStore.ts
import { useMemo } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/plugins-memory";
export function useDataStore() {
// This will cause subscriptions to run infinitely if this is not done
// Always use useMemo to prevent infinite subscription loops
const dataStore = useMemo(() => new DataStore(new MemoryPlugin("app")), []);
return dataStore;
}Critical: You must use useMemo when creating a DataStore instance. Without useMemo, a new DataStore is created on every render, which causes subscriptions to be recreated infinitely. Each new datastore instance triggers useQuery's effect to re-run, creating new subscriptions, which can cause performance issues and infinite loops.
Note: Subscriptions work via BroadcastChannel, so live updates work across different DataStore instances. You can create a new instance per component without losing reactivity, but each instance must be memoized.
Alternatively, you can use Context if you prefer a shared instance across your app. See the Best Practices guide for details.
Typing Query Results With InferType
useQuery<T> cannot infer T for you. Its only mention of T is inside the callback you supply, so there is nothing for TypeScript to infer from, and it falls back to unknown:
// data is `unknown` — every use of it needs a cast
const products = useQuery(cb => dataStore.products.subscribe().toArray(cb), []);Supply the generic instead, and get it from the schema with InferType:
type Product = InferType<typeof productSchema>;
const products = useQuery<Product[]>(cb => dataStore.products.subscribe().toArray(cb), []);This is the single most common reason people end up writing callback as any or annotating a hand-written interface. Neither is necessary — the schema already knows the shape.
import { InferType, s } from "@routier/core/schema";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { useQuery } from "@routier/react";
const productSchema = s.define("products", {
id: s.string().key().identity(),
name: s.string(),
price: s.number(),
}).compile();
class AppStore extends DataStore {
products = this.collection(productSchema).proxy().create();
}
const dataStore = new AppStore(new MemoryPlugin("products-db"));
// Declare the entity type once, off the schema. Never hand-write this shape.
type Product = InferType<typeof productSchema>;
// ✅ Pass the type as the generic. `products.data` is Product[].
export function ProductList(props: { onPick: (product: Product) => void }) {
const products = useQuery<Product[]>(
callback => dataStore.products.subscribe().toArray(callback),
[],
);
if (products.status !== "success") {
return null;
}
return (
<ul>
{products.data.map(product => (
<li key={product.id} onClick={() => props.onPick(product)}>
{product.name} — {product.price}
</li>
))}
</ul>
);
}
// The same type works for anything downstream: props, helpers, return types.
export const totalOf = (items: Product[]): number =>
items.reduce((sum, item) => sum + item.price, 0);
// A single item is `InferType<...>` on its own, not an array.
export function ProductName(props: { id: string }) {
const product = useQuery<Product | undefined>(
callback => dataStore.products
.subscribe()
.where(([p, params]) => p.id === params.id, { id: props.id })
.firstOrUndefined(callback),
[props.id],
);
if (product.status !== "success" || product.data == null) {
return null;
}
return <span>{product.data.name}</span>;
}Use InferType<typeof schema> for a single entity, InferType<typeof schema>[] for a list, and InferType<typeof schema> | undefined for firstOrUndefined. For the shape you pass to addAsync, use InferCreateType — see InferType.
Status Checking
Always check status before accessing data:
if (result.status === "pending") return <Loading />;
if (result.status === "error") return <Error error={result.error} />;
return <DataView data={result.data} />;TypeScript's discriminated unions make this safe:
// TypeScript knows data is defined when status is 'success'
if (result.status === "success") {
console.log(result.data); // ✅ Safe
}Dependencies Array
Use the deps array to control when queries re-run:
// Re-run when searchTerm changes
const results = useQuery(
(cb) =>
dataStore.products
.where((p) => p.name.includes(searchTerm))
.subscribe()
.toArray(cb),
[searchTerm] // Re-subscribe when searchTerm changes
);Return the Unsubscribe Handler
When you subscribe inside useQuery, the query chain returns an unsubscribe function. You must return it from your callback so the hook can clean up when the component unmounts or when dependencies change. If you don't, subscriptions leak and the component may not update correctly.
- Arrow expression:
(callback) => dataStore.products.subscribe().toArray(callback)— the return value is implicit. - Block body: use
returnso the handler is passed touseQuery:
const currentUserQuery = useQuery<User | undefined>(
(callback) => {
return dataStore.users
.subscribe()
.where(([u, p]) => u.userRef === p.sub, { sub: user.sub })
.firstOrUndefined(callback);
},
[dataStore, user?.sub, shouldRunUserQueries]
);For custom subscriptions (e.g. onChange), return your cleanup function the same way:
const data = useQuery((callback) => {
const sub = dataStore.products.subscribe();
const unsub = sub.onChange(() => sub.toArray(callback));
sub.toArray(callback);
return unsub; // Cleanup function
}, []);Troubleshooting
Hook Not Updating
If your component doesn't re-render when data changes:
- Ensure you're calling
.subscribe()on your collection - Return the unsubscribe handler from your query callback (the query chain returns it; with a block body use
return) - Check that dependencies are correctly specified in the deps array
Invalid Hook Call
Common causes:
- Duplicate React instances: Run
npm ls reactto check - Import from wrong package: Use
@routier/reactnot internal paths - Bundler configuration: Alias
reactandreact-domproperly
Memory Leaks
Prevent leaks by:
- Always return the unsubscribe handler from your query callback—when using
.subscribe(), return the result of the chain (e.g.return dataStore.users.subscribe().where(...).firstOrUndefined(callback)) - Not holding references to query results outside the hook
- Using the deps array to prevent unnecessary re-subscriptions
Advanced Usage
Combining with Other Hooks
function useFilteredProducts(searchTerm: string) {
const dataStore = useDataStore();
const products = useQuery(
(cb) =>
dataStore.products
.where((p) => p.name.includes(searchTerm))
.subscribe()
.toArray(cb),
[searchTerm]
);
const count = useMemo(
() => (products.status === "success" ? products.data?.length : 0),
[products]
);
return { products, count };
}Optimistic Updates
Combine with collection mutations for optimistic updates:
async function addProduct(product: Product) {
// Optimistic add
await dataStore.products.addAsync(product);
await dataStore.saveChangesAsync();
// Query automatically updates with new data
}See Also
- Live Queries Guide - Understanding live queries
- Optimistic Replication Guide - Using optimistic replication
- State Management Guide - Managing application state