Skip to content

React Best Practices ​

Build robust, performant React applications with Routier by following these patterns and practices.

Table of Contents ​

Accessing Your Data Store ​

You have flexibility in how you provide your DataStore to components. Here are the common approaches:

Simple Custom Hook ​

Create a custom hook that returns a new DataStore instance:

tsx
// hooks/useDataStore.ts
import { useMemo } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";

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 updates work across different DataStore instances. You can create a new instance in each component without losing live updates, but each instance must be memoized.

With React Context (Optional) ​

If you prefer to share a single instance through your component tree:

tsx
// DataStoreContext.tsx
import { createContext, useContext, ReactNode } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";

const DataStoreContext = createContext<DataStore | null>(null);

export function DataStoreProvider({ children }: { children: ReactNode }) {
  // This will cause subscriptions to run infinitely if this is not done
  // Always use useMemo to prevent infinite subscription loops
  const store = useMemo(() => new DataStore(new MemoryPlugin("app")), []);

  return (
    <DataStoreContext.Provider value={store}>
      {children}
    </DataStoreContext.Provider>
  );
}

export function useDataStore() {
  const context = useContext(DataStoreContext);
  if (!context) {
    throw new Error("useDataStore must be used within DataStoreProvider");
  }
  return context;
}

Important: Routier uses BroadcastChannel for subscriptions, so even different DataStore instances will receive update notifications automatically. Both approaches work seamlessly with live queries.

Debug Logging in Vite ​

Routier is built with rspack, which replaces import.meta.env with undefined in the bundle. In a Vite app, you must enable debug logging explicitly by setting globalThis.__ROUTIER_DEBUG__ at the top of your entry file (before any routier imports):

tsx
// main.tsx
if (import.meta.env?.DEV) {
  (globalThis as any).__ROUTIER_DEBUG__ = true;
}

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
// ...

For full details on enabling and disabling logging across all environments, see Debug Logging.

Common Pitfalls ​

Infinite Subscription Loops ​

Problem: Creating a new DataStore instance on every render causes infinite subscription loops.

tsx
// ❌ WRONG - This will cause infinite subscriptions
function ProductsList() {
  const dataStore = new DataStore(new MemoryPlugin("app")); // New instance every render!

  const products = useQuery(
    (cb) => dataStore.products.subscribe().toArray(cb),
    [dataStore] // dataStore reference changes every render
  );
  // This causes useQuery's effect to re-run infinitely
}

Why this happens:

  1. Component renders, creates new DataStore instance
  2. useQuery sees a new dataStore reference in dependencies
  3. Effect re-runs, creates new subscription
  4. Subscription triggers callback, component re-renders
  5. Back to step 1 - infinite loop

Solution: Always memoize your DataStore instance:

tsx
// ✅ CORRECT - Memoized instance
function ProductsList() {
  const dataStore = useDataStore(); // Memoized in hook

  const products = useQuery(
    (cb) => dataStore.products.subscribe().toArray(cb),
    [dataStore] // Stable reference
  );
}

// In your useDataStore hook:
export function useDataStore() {
  // This will cause subscriptions to run infinitely if this is not done
  const dataStore = useMemo(() => new DataStore(new MemoryPlugin("app")), []);
  return dataStore;
}

How to identify: If you see subscriptions firing repeatedly or your component re-rendering continuously, check that your DataStore is memoized with useMemo.

Error Handling ​

Standard Error Pattern ​

Always check status before accessing data:

tsx
const products = useQuery(
  (cb) => dataStore.products.subscribe().toArray(cb),
  []
);

if (products.status === "pending") {
  return <LoadingSpinner />;
}

if (products.status === "error") {
  return <ErrorMessage error={products.error} />;
}

return <ProductsList products={products.data} />;

Custom Error Component ​

tsx
interface ErrorDisplayProps {
  error: Error;
  retry?: () => void;
}

function ErrorDisplay({ error, retry }: ErrorDisplayProps) {
  return (
    <div className="error">
      <p>Something went wrong: {error.message}</p>
      {retry && <button onClick={retry}>Retry</button>}
    </div>
  );
}

// Usage
if (products.status === "error") {
  return <ErrorDisplay error={products.error} />;
}

Error Boundary Pattern ​

tsx
import { Component, ReactNode } from "react";

interface Props {
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

class QueryErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return <ErrorDisplay error={this.state.error!} />;
    }

    return this.props.children;
  }
}

// Usage
<QueryErrorBoundary>
  <ProductsList />
</QueryErrorBoundary>;

Loading States ​

Reusable Loading Component ​

tsx
interface LoadingProps {
  size?: "small" | "medium" | "large";
  message?: string;
}

function Loading({ size = "medium", message = "Loading..." }: LoadingProps) {
  return (
    <div className={`loading loading-${size}`}>
      <Spinner />
      <p>{message}</p>
    </div>
  );
}

// Usage
if (products.status === "pending") {
  return <Loading message="Loading products..." />;
}

Skeleton Screens ​

For better UX, show skeleton screens instead of spinners:

tsx
function ProductSkeleton() {
  return (
    <div className="skeleton">
      <div className="skeleton-image"></div>
      <div className="skeleton-title"></div>
      <div className="skeleton-description"></div>
    </div>
  );
}

// Usage
if (products.status === "pending") {
  return Array(3)
    .fill(0)
    .map((_, i) => <ProductSkeleton key={i} />);
}

Performance Optimization ​

Note: Routier's query evaluation is extremely fast (less than 1ms), so memoization is typically unnecessary unless you're dealing with very complex queries or very large datasets.

Debounce Search Inputs ​

tsx
import { useState, useEffect } from "react";

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

function SearchableProducts() {
  const [search, setSearch] = useState("");
  const debouncedSearch = useDebounce(search, 300);

  const products = useQuery(
    (cb) =>
      dataStore.products
        .where((p) => p.name.includes(debouncedSearch))
        .subscribe()
        .toArray(cb),
    [debouncedSearch]
  );

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      {/* Render products */}
    </>
  );
}

Split Components ​

Keep query logic separate from presentation:

tsx
// hooks/useProducts.ts
export function useProducts(searchTerm: string) {
  const dataStore = useDataStore();

  return useQuery(
    (cb) =>
      dataStore.products
        .where((p) => p.name.includes(searchTerm))
        .subscribe()
        .toArray(cb),
    [searchTerm]
  );
}

// components/ProductsList.tsx
export function ProductsList({ searchTerm }: { searchTerm: string }) {
  const products = useProducts(searchTerm);

  if (products.status === "pending") return <Loading />;
  if (products.status === "error")
    return <ErrorDisplay error={products.error} />;

  return (
    <ul>
      {products.data?.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </ul>
  );
}

Testing ​

Mock DataStore for Testing ​

tsx
// test-utils/mockStore.ts
import { MemoryPlugin } from "@routier/memory-plugin";
import { DataStore } from "@routier/datastore";

export function createMockStore() {
  return new DataStore(new MemoryPlugin("test"));
}

// Usage in tests
import { render } from "@testing-library/react";
import { DataStoreProvider } from "../contexts/DataStoreContext";

function renderWithStore(component: ReactElement, store: DataStore) {
  return render(
    <DataStoreProvider store={store}>{component}</DataStoreProvider>
  );
}

test("renders products", () => {
  const store = createMockStore();
  // Populate store with test data
  store.products.addAsync({ name: "Test Product" });

  renderWithStore(<ProductsList />, store);
  // Assertions...
});

Testing useQuery ​

tsx
import { renderHook, waitFor } from "@testing-library/react";

test("useQuery returns data", async () => {
  const store = createMockStore();
  await store.products.addAsync({ name: "Test" });
  await store.saveChangesAsync();

  const { result } = renderHook(
    () => useQuery((cb) => store.products.subscribe().toArray(cb), []),
    {
      wrapper: ({ children }) => (
        <DataStoreProvider store={store}>{children}</DataStoreProvider>
      ),
    }
  );

  await waitFor(() => {
    expect(result.current.status).toBe("success");
  });

  expect(result.current.data).toHaveLength(1);
});

Understanding Query Patterns ​

Decision Guide: Choosing the Right Pattern ​

Ask yourself:

  1. Does my data change during the component's lifetime?
  2. Do I need to show initial data, or only changes?
PatternUse WhenSyntax
Live subscriptionData changes, need both initial + updates.subscribe().toArray(cb)
One-time queryStatic data, fetch once.toArray(cb) (no subscribe)

Live Queries vs One-Time Queries ​

With .subscribe() - Dynamic data that changes:

tsx
// ✅ Use .subscribe() when data changes
function ProductsList() {
  const products = useQuery(
    (callback) => dataStore.products.subscribe().toArray(callback),
    []
  );
  // Automatically updates when products are added/updated/removed
}

Without .subscribe() - Static data that doesn't change:

tsx
// ✅ Use without .subscribe() for static/one-time data
function ConfigDisplay() {
  const config = useQuery(
    (callback) => dataStore.config.toArray(callback), // No .subscribe()
    []
  );
  // Runs once, never updates - perfect for app configuration
}

Common Patterns ​

Refetch Pattern ​

Implement a manual refetch:

tsx
function RefetchableProducts() {
  const [key, setKey] = useState(0);

  const products = useQuery(
    (cb) => dataStore.products.subscribe().toArray(cb),
    [key] // Re-run when key changes
  );

  const refetch = () => setKey((prev) => prev + 1);

  return (
    <>
      <button onClick={refetch}>Refresh</button>
      {/* Render products */}
    </>
  );
}

Conditional Queries ​

Skip queries based on conditions:

tsx
function ConditionalQuery({ userId }: { userId?: string }) {
  const dataStore = useDataStore();

  const orders = useQuery(
    (cb) => {
      if (!userId) return; // Skip query
      return dataStore.orders
        .where((o) => o.userId === userId)
        .subscribe()
        .toArray(cb);
    },
    [userId]
  );

  if (!userId) return <div>Please select a user</div>;
  // Rest of component...
}

Optimistic Updates Pattern ​

Combine queries with mutations:

tsx
async function addProduct(product: ProductData) {
  // Add optimistically
  await dataStore.products.addAsync(product);
  await dataStore.saveChangesAsync();

  // Query automatically updates with new data
}

function ProductsWithAdd() {
  const products = useQuery(
    (cb) => dataStore.products.subscribe().toArray(cb),
    []
  );

  const handleAdd = async (product: ProductData) => {
    await addProduct(product);
  };

  return (
    <>
      <AddProductForm onSubmit={handleAdd} />
      {/* Render products */}
    </>
  );
}

Computed Values ​

Derive data from queries:

tsx
function ProductStats() {
  const dataStore = useDataStore();

  const products = useQuery(
    (cb) => dataStore.products.subscribe().toArray(cb),
    []
  );

  const stats = useMemo(() => {
    if (products.status !== "success") return null;

    return {
      total: products.data!.length,
      totalValue: products.data!.reduce((sum, p) => sum + p.price, 0),
      averagePrice:
        products.data!.reduce((sum, p) => sum + p.price, 0) /
        products.data!.length,
    };
  }, [products]);

  if (products.status === "pending") return <Loading />;
  if (!stats) return null;

  return (
    <div>
      <p>Total Products: {stats.total}</p>
      <p>Total Value: ${stats.totalValue}</p>
      <p>Average Price: ${stats.averagePrice}</p>
    </div>
  );
}

Anti-Patterns to Avoid ​

Don't Call Queries Outside Components ​

tsx
// ❌ Bad
const data = useQuery(/* ... */); // Called outside component

function MyComponent() {
  return <div>{data.data}</div>;
}

// ✅ Good
function MyComponent() {
  const data = useQuery(/* ... */); // Inside component
  return <div>{data.data}</div>;
}

Don't Forget Dependencies ​

tsx
// ❌ Bad - will not update when searchTerm changes
const products = useQuery(
  (cb) =>
    dataStore.products
      .where((p) => p.name.includes(searchTerm))
      .subscribe()
      .toArray(cb),
  [] // Missing searchTerm
);

// ✅ Good
const products = useQuery(
  (cb) =>
    dataStore.products
      .where((p) => p.name.includes(searchTerm))
      .subscribe()
      .toArray(cb),
  [searchTerm] // Include dependencies
);

Don't Access Data Without Status Check ​

tsx
// ❌ Bad
if (result.data) {
  // Could be undefined
  console.log(result.data);
}

// ✅ Good
if (result.status === "success") {
  console.log(result.data); // TypeScript knows data is defined
}

Next Steps ​

Released under the MIT License.