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

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

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

Examples:

  • Products list (changes) → Use .subscribe()
  • App config (static) → No .subscribe()

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


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