Skip to content

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:

  1. Setup: Your query function sets up a subscription and provides a callback
  2. Updates: The callback receives new data as it changes
  3. Cleanup: When dependencies change or the component unmounts, subscriptions are cleaned up
  4. State: Returns a discriminated union for safe status checking
ts
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

ts
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 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:

tsx
// ✅ 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:

tsx
// ✅ 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:

tsx
// ❌ 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

Examples

Basic List Query

Subscribe to an entire collection:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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():

tsx
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 TypePatternWhen to Use
Live UpdatesuseQuery + .subscribe().toArray(callback)Data changes, need initial + updates
One-Time FetchuseQuery + .toArray(callback)Effect-based fetch with explicit status
Suspense Read (React 19)use(toArrayAsync()) with a stable/cached PromiseBoundary-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:

tsx
// 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:

ts
// 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:

ts
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.

tsx
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:

tsx
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:

tsx
// 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:

tsx
// 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 return so the handler is passed to useQuery:
tsx
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:

tsx
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 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

Advanced Usage

Combining with Other Hooks

tsx
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:

tsx
async function addProduct(product: Product) {
  // Optimistic add
  await dataStore.products.addAsync(product);
  await dataStore.saveChangesAsync();

  // Query automatically updates with new data
}

See Also

Released under the MIT License.