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.
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 resultsdeps- 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:
// ✅ Live updates - re-renders when products change
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();
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 same
Use without .subscribe() when:
- Fetching static data that won’t change
- Performing one-time initialization
- Loading data for a single render
Deferring the First Query
Use .defer() to skip the initial query and only listen to changes:
// Only gets data AFTER the first change, ignores initial state
// Important: defer() must come before subscribe()
const products = useQuery(
(callback) => dataStore.products.defer().subscribe().toArray(callback),
[]
);
Behavior with .defer():
- Component mounts, stays in
pendingstate - First query execution is skipped (only the first one)
- Waits for the first change event
- On first change, queries and updates
- On all subsequent changes, queries and updates normally
Important: .defer() must be called before .subscribe() in the query chain. The order matters.
Use .defer() when:
- Building activity feeds or notifications
- Showing only new items after mount
- Analytics dashboards that update on events
- Chat applications (only new messages)
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),
[dataStore]
);
// 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),
[dataStore]
);
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),
[dataStore, searchTerm] // Re-run when store or 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.status === "success" &&
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),
[dataStore, productId] // Re-run when store or productId changes
);
if (product.status === "pending") return <div>Loading...</div>;
if (product.status === "error") return <div>Product not found</div>;
return (
<div>
{product.status === "success" && (
<>
<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),
[dataStore]
);
if (products.status === "pending") return <div>Loading...</div>;
if (products.status === "error")
return <div>Error: {products.error.message}</div>;
return (
<ul>
{products.status === "success" &&
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),
[dataStore, cutoffDate, days] // Re-run when store or 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.status === "success" &&
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;
},
[dataStore]
);
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),
[dataStore]
);
const categories = useQuery(
(callback) => dataStore.categories.subscribe().toArray(callback),
[dataStore]
);
const productCount = useQuery(
(callback) => dataStore.products.subscribe().count(callback),
[dataStore]
);
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()
[dataStore]
);
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>
);
}Deferred Queries (Change-Only Subscriptions)
Only listen to changes, ignore initial state:
import { useQuery } from "@routier/react";
import { useDataStore } from "../useDataStore";
export function DeferredNotifications() {
const dataStore = useDataStore();
// Only shows NEW notifications after component mounts
// Ignores historical notifications
const notifications = useQuery(
(callback) => dataStore.notifications.subscribe().defer().toArray(callback),
[dataStore]
);
if (notifications.status === "pending") {
return <div>Waiting for new notifications...</div>;
}
if (notifications.status === "error") {
return <div>Error loading notifications</div>;
}
return (
<div>
<h2>New Notifications</h2>
{notifications.data?.length === 0 ? (
<p>No new notifications</p>
) : (
<ul>
{notifications.data?.map((notif) => (
<li key={notif.id}>{notif.message}</li>
))}
</ul>
)}
</div>
);
}Quick Reference
| Query Type | Pattern | When to Use |
|---|---|---|
| Live Updates | .subscribe().toArray(callback) | Data changes, need initial + updates |
| One-Time Fetch | .toArray(callback) | Static data, fetch once only |
| Change-Only | .subscribe().defer().toArray(callback) | Only show new data, ignore current state |
Examples:
- Products list (changes) → Use
.subscribe() - App config (static) → No
.subscribe() - Notifications (new only) → Use
.defer()
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.
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
);
Cleanup Functions
Return a cleanup function from your subscription:
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 - Check that dependencies are correctly specified in the deps array
- Verify the cleanup function is being returned
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 returning cleanup functions from subscriptions
- 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