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
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.
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 like useEffect dependencies)
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:
>
1
2
3
4
5
6
7
8
9
// ✅ Live updates - return the query so useQuery can unsubscribeconstproducts=useQuery((callback)=>dataStore.products.subscribe().toArray(callback),[]);// When you add a product, the component automatically updatesawaitdataStore.products.addAsync({name:"New Product"});awaitdataStore.saveChangesAsync();
With a block body, explicitly return the result of the chain:
>
1
2
3
4
5
6
7
8
9
10
// ✅ Return the unsubscribe handler (required for cleanup)constcurrentUserQuery=useQuery<User|undefined>((callback)=>{returndataStore.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:
>
1
2
3
4
5
6
7
8
9
// ❌ One-time only - never updatesconstproducts=useQuery((callback)=>dataStore.products.toArray(callback),// No .subscribe()[]);// Adding products won't cause a re-renderawaitdataStore.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
Examples
Basic List Query
Subscribe to an entire collection:
import{useQuery}from"@routier/react";import{useDataStore}from"../useDataStore";// Your app's contextexportfunctionProductsList(){constdataStore=useDataStore();// Subscribe to live query resultsconstproducts=useQuery((callback)=>dataStore.products.subscribe().toArray(callback),[]);// Handle loading stateif(products.status==="pending"){return<div>Loading...</div>;}// Handle error stateif(products.status==="error"){return<div>Error: {products.error?.message}</div>;}// Render datareturn(<ul>{products.data?.map((product)=>(<likey={product.id}>{product.name}</li>))}</ul>);}
Count Query
Get the count of items:
import{useQuery}from"@routier/react";import{useDataStore}from"../useDataStore";exportfunctionProductCount(){constdataStore=useDataStore();// Query a countconstcountResult=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";exportfunctionFilteredProducts(){constdataStore=useDataStore();const[searchTerm,setSearchTerm]=useState("");// Re-run query when search term changesconstproducts=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><inputvalue={searchTerm}onChange={(e)=>setSearchTerm(e.target.value)}placeholder="Search products..."/><ul>{products.data?.map((product)=>(<likey={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";exportfunctionSingleProduct({productId}:{productId:string}){constdataStore=useDataStore();// Query a single item by IDconstproduct=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";exportfunctionSortedProducts(){constdataStore=useDataStore();// Sort products by nameconstproducts=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)=>(<likey={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";exportfunctionRecentOrders({days}:{days:number}){constdataStore=useDataStore();constcutoffDate=newDate(Date.now()-days*24*60*60*1000);// Query with paginationconstorders=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)=>(<likey={order.id}>{order.total} - {newDate(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";exportfunctionCustomSubscription(){constdataStore=useDataStore();// Custom subscription with explicit cleanupconstdata=useQuery((callback)=>{constsubscription=dataStore.products.subscribe();// Set up an onChange handlerconstunsubscribe=subscription.onChange(()=>{subscription.toArray(callback);});// Initial data fetchsubscription.toArray(callback);// Return cleanup functionreturnunsubscribe;},[]);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";exportfunctionMultipleQueries(){constdataStore=useDataStore();// Multiple independent queries in one componentconstproducts=useQuery((callback)=>dataStore.products.subscribe().toArray(callback),[]);constcategories=useQuery((callback)=>dataStore.categories.subscribe().toArray(callback),[]);constproductCount=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)=>(<likey={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";exportfunctionOneTimeQuery(){constdataStore=useDataStore();// This query runs ONCE and never updates// Perfect for static configuration or initial data that won't changeconstconfig=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>);}
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
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:
>
1
2
3
4
5
6
7
8
9
10
11
// hooks/useDataStore.tsimport{useMemo}from"react";import{DataStore}from"@routier/datastore";import{MemoryPlugin}from"@routier/plugins-memory";exportfunctionuseDataStore(){// This will cause subscriptions to run infinitely if this is not done// Always use useMemo to prevent infinite subscription loopsconstdataStore=useMemo(()=>newDataStore(newMemoryPlugin("app")),[]);returndataStore;}
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.
// 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:
>
1
2
3
4
5
6
7
8
9
// Re-run when searchTerm changesconstresults=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 return so the handler is passed to useQuery:
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 react to check
Import from wrong package: Use @routier/react not internal paths
Bundler configuration: Alias react and react-dom properly
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
Combine with collection mutations for optimistic updates:
>
1
2
3
4
5
6
7
asyncfunctionaddProduct(product:Product){// Optimistic addawaitdataStore.products.addAsync(product);awaitdataStore.saveChangesAsync();// Query automatically updates with new data}