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:

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

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

Common Pitfalls

Infinite Subscription Loops

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

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

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

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

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

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

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:

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

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:

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

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

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?
Pattern Use When Syntax
Live subscription Data changes, need both initial + updates .subscribe().toArray(cb)
One-time query Static data, fetch once .toArray(cb) (no subscribe)
Defer updates Only show new data, ignore current .subscribe().defer().toArray(cb)

Live Queries vs One-Time Queries

With .subscribe() - Dynamic data that changes:

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

// ✅ 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
}

When to Use .defer()

The .defer() method skips the first query execution only, then listens to all subsequent changes:

// Perfect for real-time notifications
// Important: defer() must come before subscribe()
function NotificationsFeed() {
  const notifications = useQuery(
    (callback) => dataStore.notifications.defer().subscribe().toArray(callback),
    []
  );

  // Component mounts with pending state
  // First query is skipped (only the first execution)
  // When the first notification arrives, it queries and updates
  // All subsequent notifications trigger queries normally
  // User only sees new notifications, not historical ones
}

Important: .defer() must be called before .subscribe() in the query chain. The order matters.

When to use .defer():

  • Activity feeds and notifications
  • Real-time chat messages (only new messages after mount)
  • Event-driven dashboards
  • Any scenario where you only care about changes, not current state

Real-world example - Chat Messages:

function ChatWindow() {
  const dataStore = useDataStore();

  // Only show NEW messages after user joins the chat
  // Don't load entire chat history on mount
  // Note: defer() comes before subscribe()
  const messages = useQuery(
    (callback) => dataStore.messages.defer().subscribe().toArray(callback),
    []
  );

  return (
    <div>
      {messages.status === "pending" && (
        <div>Joined chat. Waiting for messages...</div>
      )}
      {messages.status === "success" && (
        <ul>
          {messages.data?.map((msg) => (
            <li key={msg.id}>{msg.text}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Comparison:

// Without .defer() - Loads all messages immediately
const messages = useQuery(
  (cb) => dataStore.messages.subscribe().toArray(cb),
  []
);
// Shows: [message1, message2, message3, ...] (entire history)

// With .defer() - Skips first execution, then shows all messages on changes
// Note: defer() must come before subscribe()
const messages = useQuery(
  (cb) => dataStore.messages.defer().subscribe().toArray(cb),
  []
);
// Shows: [] initially (first query skipped)
// Then on first change: [message4, message5, ...] (all messages at that point)
// Then on subsequent changes: updates with all current messages

Common Patterns

Refetch Pattern

Implement a manual refetch:

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:

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:

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:

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

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

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

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