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
>
1
2
3
4
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

>
1
2
3
4
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:

>
1
2
3
4
5
6
7
8
9
// ✅ 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:

>
1
2
3
4
5
6
7
8
9
10
// ✅ 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:

>
1
2
3
4
5
6
7
8
9
// ❌ 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:

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>
  );
}

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

>
1
2
3
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:

>
1
2
3
4
// 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 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:
>
1
2
3
4
5
6
7
8
9
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:

>
1
2
3
4
5
6
7
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

>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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:

>
1
2
3
4
5
6
7
async function addProduct(product: Product) {
  // Optimistic add
  await dataStore.products.addAsync(product);
  await dataStore.saveChangesAsync();

  // Query automatically updates with new data
}

See Also


This site uses Just the Docs, a documentation theme for Jekyll.