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
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 results
  • 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:

// ✅ 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():

  1. Component mounts, stays in pending state
  2. First query execution is skipped (only the first one)
  3. Waits for the first change event
  4. On first change, queries and updates
  5. 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 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 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