Skip to content

Playground

The Routier playground runs real examples in your browser: schemas, queries, live subscriptions, a paged live data grid, React, and IndexedDB persistence. There's nothing to install and no account to create, and the code on screen is the exact file that runs.

Open the Playground →

Examples

Schemas & CRUD

Define a schema, then add, query, update, and remove entities. Run it →

ts
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";

type Log = (message: string, value?: unknown) => void;

// A schema describes the entity once: keys, defaults, and types all flow from it.
const productSchema = s
  .define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    category: s.string(),
    price: s.number(),
    inStock: s.boolean().default(true),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

class ShopStore extends DataStore {
  products = this.collection(productSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin(`playground-crud-${Date.now()}`));
  }
}

export async function run(log: Log) {
  const store = new ShopStore();

  // Create: identity keys and defaults are filled in for you.
  const added = await store.products.addAsync(
    { name: "Mechanical Keyboard", category: "accessories", price: 129 },
    { name: "4K Monitor", category: "displays", price: 399 },
    { name: "USB-C Hub", category: "accessories", price: 49 },
  );
  await store.saveChangesAsync();
  log("Added three products", added);

  // Read: filter and sort with typed lambdas.
  const accessories = await store.products
    .where(p => p.category === "accessories")
    .sortDescending(p => p.price)
    .toArrayAsync();
  log("Accessories, most expensive first", accessories.map(p => `${p.name} ($${p.price})`));

  // Parameterized filters keep values out of the expression.
  const affordable = await store.products.where(([p, params]) => p.price < params.max, { max: 150 }).countAsync();
  log("Products under $150", affordable);

  // Update: entities are change-tracked, so assign and save.
  const monitor = await store.products.firstAsync(p => p.name === "4K Monitor");
  monitor.price = 349;
  await store.saveChangesAsync();
  log("Monitor after price change", await store.products.firstAsync(p => p.name === "4K Monitor"));

  // Delete.
  await store.products.removeAsync(monitor);
  await store.saveChangesAsync();
  log("Products remaining", await store.products.countAsync());
}

Live queries

.subscribe() turns a query live. The callback receives the current result and runs again every time a saved change affects it. Run it →

ts
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { s } from "@routier/core/schema";

type Log = (message: string, value?: unknown) => void;

const taskSchema = s
  .define("tasks", {
    id: s.string().key().identity(),
    title: s.string(),
    done: s.boolean().default(false),
  })
  .compile();

class TaskStore extends DataStore {
  tasks = this.collection(taskSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin(`playground-live-${Date.now()}`));
  }
}

const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

export async function run(log: Log) {
  const store = new TaskStore();

  // subscribe() makes the query live: the callback receives the current result now,
  // and again every time a saved change affects it.
  const unsubscribe = store.tasks
    .where(t => t.done === false)
    .subscribe()
    .toArray(result => {
      if (result.ok === "error") {
        log("Query failed", result.error);
        return;
      }

      log(`Live result: ${result.data.length} open task(s)`, result.data.map(t => t.title));
    });

  await pause(700);
  log("Adding two tasks…");
  const [docs, playground] = await store.tasks.addAsync(
    { title: "Write the docs" },
    { title: "Build the playground" },
  );
  await store.saveChangesAsync();

  await pause(700);
  log("Completing “Build the playground”…");
  playground.done = true;
  await store.saveChangesAsync();

  await pause(700);
  log("Removing “Write the docs”…");
  await store.tasks.removeAsync(docs);
  await store.saveChangesAsync();

  await pause(700);
  unsubscribe();
  log("Unsubscribed. Later changes no longer reach the callback.");
}

Live data grid

A searchable, sortable, paged product grid. The visible page is one live query, where → sort → skip → take → subscribe, and a second subscribed count drives the pager. Turn on Simulate traffic to add, update, and remove rows in the background: the current page refreshes itself and changed rows flash. See Pagination for the pattern. Try it →

tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery, type LiveQueryState } from "@routier/react";

const productSchema = s
  .define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    category: s.string(),
    price: s.number(),
    stock: s.number(),
    updatedAt: s.date().default(() => new Date()),
  })
  .compile();

type Product = InferType<typeof productSchema>;

class InventoryStore extends DataStore {
  products = this.collection(productSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin("playground-grid"));
  }
}

type SortKey = "name" | "category" | "price" | "stock" | "updatedAt";
type Filters = { category: string; search: string };

const SORT_SELECTORS: Record<SortKey, (p: Product) => Product[keyof Product]> = {
  name: p => p.name,
  category: p => p.category,
  price: p => p.price,
  stock: p => p.stock,
  updatedAt: p => p.updatedAt,
};

// One parameterized filter covers every combination of search box and category menu.
function matching(store: InventoryStore, filters: Filters) {
  return store.products.where(
    ([p, params]) =>
      (params.category === "All" || p.category === params.category) &&
      p.name.toLowerCase().includes(params.search),
    { category: filters.category, search: filters.search.trim().toLowerCase() },
  );
}

export function LiveGrid() {
  const store = useMemo(() => new InventoryStore(), []);
  const [filters, setFilters] = useState<Filters>({ category: "All", search: "" });
  const [sort, setSort] = useState<{ key: SortKey; descending: boolean }>({ key: "name", descending: false });
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(10);
  const [simulating, setSimulating] = useState(false);
  const [activity, setActivity] = useState<string[]>([]);

  useEffect(() => {
    void seed(store);
  }, [store]);

  // The visible page is a single live query: filter → sort → skip/take → subscribe.
  // Changing filters, sort, or page rebuilds it; saved data changes re-run it.
  const rows = useQuery<Product[]>(
    callback => {
      const query = matching(store, filters);
      const selector = SORT_SELECTORS[sort.key];
      const ordered = sort.descending ? query.sortDescending(selector) : query.sort(selector);

      return ordered
        .skip((page - 1) * pageSize)
        .take(pageSize)
        .subscribe()
        .toArray(callback);
    },
    [store, filters, sort, page, pageSize],
  );

  // A second live query keeps the pager's total current.
  const total = useQuery<number>(
    callback => matching(store, filters).subscribe().count(callback),
    [store, filters],
  );

  const visible = useLastSuccess(rows, [] as Product[]);
  const count = useLastSuccess(total, 0);
  const pageCount = Math.max(1, Math.ceil(count / pageSize));

  // Rows removed from under the last page: step back to a page that exists.
  useEffect(() => {
    if (page > pageCount) setPage(pageCount);
  }, [page, pageCount]);

  const { changed, refreshes } = useChangedRows(visible, JSON.stringify([filters, sort, page, pageSize]));

  const visibleRef = useRef(visible);
  visibleRef.current = visible;

  useEffect(() => {
    if (!simulating) return;

    const timer = setInterval(async () => {
      const message = await simulateTraffic(store, visibleRef.current);
      setActivity(current => [message, ...current].slice(0, 5));
    }, 900);

    return () => clearInterval(timer);
  }, [simulating, store]);

  const updateFilters = (next: Partial<Filters>) => {
    setFilters(current => ({ ...current, ...next }));
    setPage(1);
  };

  const toggleSort = (key: SortKey) => {
    setSort(current => ({ key, descending: current.key === key ? !current.descending : false }));
    setPage(1);
  };

  const addProduct = async () => {
    const [product] = await store.products.addAsync(makeProduct(1000 + Math.floor(Math.random() * 9000)));
    await store.saveChangesAsync();
    setActivity(current => [`You added ${product.name}`, ...current].slice(0, 5));
  };

  // Rows are change-tracked entities: mutate, then save.
  const adjustStock = async (product: Product, delta: number) => {
    product.stock = Math.max(0, product.stock + delta);
    product.updatedAt = new Date();
    await store.saveChangesAsync();
  };

  const remove = async (product: Product) => {
    await store.products.removeAsync(product);
    await store.saveChangesAsync();
  };

  const first = count === 0 ? 0 : (page - 1) * pageSize + 1;
  const last = Math.min(page * pageSize, count);

  return (
    <div className="grid-demo">
      <div className="grid-toolbar">
        <input
          type="search"
          aria-label="Search products"
          placeholder="Search products…"
          value={filters.search}
          onChange={e => updateFilters({ search: e.target.value })}
        />
        <select aria-label="Category" value={filters.category} onChange={e => updateFilters({ category: e.target.value })}>
          {["All", ...CATEGORIES].map(category => (
            <option key={category}>{category}</option>
          ))}
        </select>
        <button type="button" onClick={addProduct}>
          + Add product
        </button>
        <button
          type="button"
          className={simulating ? "toggle is-on" : "toggle"}
          aria-pressed={simulating}
          onClick={() => setSimulating(on => !on)}
        >
          {simulating ? "■ Stop traffic" : "▶ Simulate traffic"}
        </button>
      </div>

      <div className="grid-scroll">
        <table className="grid">
          <thead>
            <tr>
              {COLUMNS.map(column => (
                <th
                  key={column.key}
                  className={column.numeric ? "numeric" : undefined}
                  aria-sort={sort.key === column.key ? (sort.descending ? "descending" : "ascending") : "none"}
                >
                  <button type="button" onClick={() => toggleSort(column.key)}>
                    {column.label}
                    <span className="sort-indicator">{sort.key === column.key ? (sort.descending ? "▼" : "▲") : ""}</span>
                  </button>
                </th>
              ))}
              <th>
                <span className="visually-hidden">Actions</span>
              </th>
            </tr>
          </thead>
          <tbody>
            {rows.status === "error" && (
              <tr>
                <td colSpan={6} className="grid-empty">
                  Error: {rows.error.message}
                </td>
              </tr>
            )}
            {rows.status === "success" && visible.length === 0 && (
              <tr>
                <td colSpan={6} className="grid-empty">
                  No products match.
                </td>
              </tr>
            )}
            {visible.map(product => (
              <tr key={product.id} className={changed.has(product.id) ? "is-changed" : undefined}>
                <td>{product.name}</td>
                <td>
                  <span className="pill">{product.category}</span>
                </td>
                <td className="numeric">${product.price.toFixed(2)}</td>
                <td className="numeric">
                  <span className="stepper">
                    <button type="button" aria-label={`Decrease stock of ${product.name}`} onClick={() => adjustStock(product, -1)}>

                    </button>
                    <span className={product.stock < 5 ? "low-stock" : undefined}>{product.stock}</span>
                    <button type="button" aria-label={`Increase stock of ${product.name}`} onClick={() => adjustStock(product, 1)}>
                      +
                    </button>
                  </span>
                </td>
                <td className="numeric muted-cell">{new Date(product.updatedAt).toLocaleTimeString()}</td>
                <td className="numeric">
                  <button type="button" className="row-delete" aria-label={`Remove ${product.name}`} onClick={() => remove(product)}>

                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="grid-footer">
        <span>
          {first}–{last} of {count}
          <span className="live-dot">● live · {refreshes} refreshes</span>
        </span>
        <span className="pager">
          <select
            aria-label="Rows per page"
            value={pageSize}
            onChange={e => {
              setPageSize(Number(e.target.value));
              setPage(1);
            }}
          >
            {[10, 25, 50].map(size => (
              <option key={size} value={size}>
                {size} / page
              </option>
            ))}
          </select>
          <button type="button" aria-label="First page" disabled={page === 1} onClick={() => setPage(1)}>
            «
          </button>
          <button type="button" aria-label="Previous page" disabled={page === 1} onClick={() => setPage(p => p - 1)}>

          </button>
          <span className="page-label">
            Page {page} of {pageCount}
          </span>
          <button type="button" aria-label="Next page" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>

          </button>
          <button type="button" aria-label="Last page" disabled={page >= pageCount} onClick={() => setPage(pageCount)}>
            »
          </button>
        </span>
      </div>

      {activity.length > 0 && (
        <ul className="activity" aria-label="Recent changes">
          {activity.map((message, index) => (
            <li key={`${activity.length - index}-${message}`}>{message}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
  { key: "name", label: "Product" },
  { key: "category", label: "Category" },
  { key: "price", label: "Price", numeric: true },
  { key: "stock", label: "Stock", numeric: true },
  { key: "updatedAt", label: "Updated", numeric: true },
];

/** Keeps showing the last delivered data while a rebuilt query (new page, sort, filter) loads. */
function useLastSuccess<T>(state: LiveQueryState<T>, initial: T): T {
  const last = useRef(initial);

  if (state.status === "success") last.current = state.data;

  return last.current;
}

/** Demo chrome: flags rows whose values changed while the same page stayed on screen. */
function useChangedRows(rows: Product[], windowKey: string) {
  const signature = (p: Product) => `${p.name}|${p.price}|${p.stock}`;
  const previous = useRef({ rows, windowKey, signatures: new Map<string, string>() });
  const [changed, setChanged] = useState<Set<string>>(new Set());
  const [refreshes, setRefreshes] = useState(0);

  useEffect(() => {
    // Same delivery as last time (for example, only the page number changed so far).
    if (rows === previous.current.rows) return;

    const before = previous.current;
    previous.current = { rows, windowKey, signatures: new Map(rows.map(p => [p.id, signature(p)])) };

    // A different window is a new page, not a change to this one.
    if (before.windowKey !== windowKey) return;

    setRefreshes(n => n + 1);

    const ids = rows.filter(p => before.signatures.get(p.id) !== signature(p)).map(p => p.id);

    if (ids.length === 0) return;

    setChanged(new Set(ids));
    const timer = setTimeout(() => setChanged(new Set()), 1000);

    return () => clearTimeout(timer);
  }, [rows, windowKey]);

  return { changed, refreshes };
}

const CATEGORIES = ["Audio", "Cameras", "Computers", "Gaming", "Phones", "Wearables"];

const PRODUCT_NAMES: Record<string, string[]> = {
  Audio: ["Headphones", "Speaker", "Earbuds", "Soundbar"],
  Cameras: ["Mirrorless", "Action Cam", "Lens", "Tripod"],
  Computers: ["Laptop", "Monitor", "Keyboard", "Dock"],
  Gaming: ["Controller", "Headset", "Console", "Racing Wheel"],
  Phones: ["Smartphone", "Charger", "Case", "Power Bank"],
  Wearables: ["Smartwatch", "Fitness Band", "Smart Ring", "AR Glasses"],
};

const MODELS = ["Air", "Pro", "Max", "Mini", "Neo", "Ultra", "Lite", "Edge"];

function makeProduct(n: number) {
  const category = CATEGORIES[n % CATEGORIES.length];
  const names = PRODUCT_NAMES[category];

  return {
    name: `${names[Math.floor(n / CATEGORIES.length) % names.length]} ${MODELS[(n * 5) % MODELS.length]} ${100 + n}`,
    category,
    price: 19.99 + ((n * 37) % 480),
    stock: (n * 13) % 60,
  };
}

async function seed(store: InventoryStore) {
  if ((await store.products.countAsync()) > 0) return;

  await store.products.addAsync(...Array.from({ length: 240 }, (_, n) => makeProduct(n)));
  await store.saveChangesAsync();
}

/** Background writes. The grid never hears about them directly, only through its live queries. */
async function simulateTraffic(store: InventoryStore, visible: Product[]): Promise<string> {
  const roll = Math.random();

  if (roll < 0.25) {
    const [product] = await store.products.addAsync(makeProduct(1000 + Math.floor(Math.random() * 9000)));
    await store.saveChangesAsync();
    return `Added ${product.name}`;
  }

  // Favor rows on the current page so the live refresh is easy to see.
  const count = await store.products.countAsync();
  const [product] =
    visible.length > 0 && Math.random() < 0.7
      ? [visible[Math.floor(Math.random() * visible.length)]]
      : await store.products.sort(p => p.id).skip(Math.floor(Math.random() * count)).take(1).toArrayAsync();

  if (product == null) return "Nothing to change";

  if (roll < 0.9) {
    product.price = Math.round(product.price * (0.8 + Math.random() * 0.4) * 100) / 100;
    product.stock = Math.floor(Math.random() * 60);
    product.updatedAt = new Date();
    await store.saveChangesAsync();
    return `Updated ${product.name}: $${product.price.toFixed(2)}, ${product.stock} in stock`;
  }

  await store.products.removeAsync(product);
  await store.saveChangesAsync();
  return `Removed ${product.name}`;
}

React + useQuery

A todo list whose components re-render from live queries. See the React adapter for more. Try it →

tsx
import { useMemo, useState, type FormEvent } from "react";
import { DataStore } from "@routier/datastore";
import { MemoryPlugin } from "@routier/memory-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery } from "@routier/react";

const todoSchema = s
  .define("todos", {
    id: s.string().key().identity(),
    title: s.string(),
    done: s.boolean().default(false),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

type Todo = InferType<typeof todoSchema>;

class TodoStore extends DataStore {
  todos = this.collection(todoSchema).proxy().create();

  constructor() {
    super(new MemoryPlugin("playground-react"));
  }
}

export function TodoApp() {
  // Memoize the store: a new DataStore on every render would resubscribe forever.
  const store = useMemo(() => new TodoStore(), []);
  const [title, setTitle] = useState("");

  // Both hooks re-render the component whenever a saved change affects their query.
  const todos = useQuery<Todo[]>(
    callback => store.todos.sort(t => t.createdAt).subscribe().toArray(callback),
    [store],
  );
  const remaining = useQuery<number>(
    callback => store.todos.where(t => t.done === false).subscribe().count(callback),
    [store],
  );

  const add = async (event: FormEvent) => {
    event.preventDefault();

    if (title.trim() === "") {
      return;
    }

    await store.todos.addAsync({ title: title.trim() });
    await store.saveChangesAsync();
    setTitle("");
  };

  const toggle = async (todo: Todo) => {
    todo.done = !todo.done;
    await store.saveChangesAsync();
  };

  const remove = async (todo: Todo) => {
    await store.todos.removeAsync(todo);
    await store.saveChangesAsync();
  };

  if (todos.status === "pending") return <p>Loading…</p>;
  if (todos.status === "error") return <p>Error: {todos.error.message}</p>;

  return (
    <div className="demo">
      <form onSubmit={add} className="demo-form">
        <input value={title} onChange={e => setTitle(e.target.value)} placeholder="What needs doing?" />
        <button type="submit">Add</button>
      </form>

      <ul className="demo-list">
        {todos.data.map(todo => (
          <li key={todo.id} className={todo.done ? "is-done" : undefined}>
            <label>
              <input type="checkbox" checked={todo.done} onChange={() => toggle(todo)} />
              <span>{todo.title}</span>
            </label>
            <button type="button" onClick={() => remove(todo)} aria-label={`Remove ${todo.title}`}>

            </button>
          </li>
        ))}
      </ul>

      <p className="demo-footer">
        {todos.data.length === 0
          ? "No todos yet. Add one above."
          : `${remaining.status === "success" ? remaining.data : "…"} of ${todos.data.length} remaining`}
      </p>
    </div>
  );
}

IndexedDB persistence

The same store API on the Dexie plugin. Add a note, reload the page, and it's still there. Try it →

tsx
import { useMemo, useState, type FormEvent } from "react";
import { DataStore } from "@routier/datastore";
import { DexiePlugin } from "@routier/dexie-plugin";
import { InferType, s } from "@routier/core/schema";
import { useQuery } from "@routier/react";

const noteSchema = s
  .define("notes", {
    id: s.string().key().identity(),
    text: s.string(),
    createdAt: s.date().default(() => new Date()),
  })
  .compile();

type Note = InferType<typeof noteSchema>;

class NotesStore extends DataStore {
  notes = this.collection(noteSchema).proxy().create();

  constructor() {
    // IndexedDB through Dexie, so notes survive a page reload.
    // Swap in `new MemoryPlugin("notes")` and nothing else in this file changes.
    super(new DexiePlugin("routier-playground-notes"));
  }
}

export function NotesApp() {
  const store = useMemo(() => new NotesStore(), []);
  const [text, setText] = useState("");

  const notes = useQuery<Note[]>(
    callback => store.notes.sortDescending(n => n.createdAt).subscribe().toArray(callback),
    [store],
  );

  const add = async (event: FormEvent) => {
    event.preventDefault();

    if (text.trim() === "") {
      return;
    }

    await store.notes.addAsync({ text: text.trim() });
    await store.saveChangesAsync();
    setText("");
  };

  const remove = async (note: Note) => {
    await store.notes.removeAsync(note);
    await store.saveChangesAsync();
  };

  const clear = async () => {
    const all = await store.notes.toArrayAsync();
    await store.notes.removeAsync(...all);
    await store.saveChangesAsync();
  };

  if (notes.status === "pending") return <p>Opening IndexedDB…</p>;
  if (notes.status === "error") return <p>Error: {notes.error.message}</p>;

  return (
    <div className="demo">
      <form onSubmit={add} className="demo-form">
        <input value={text} onChange={e => setText(e.target.value)} placeholder="Write a note, then reload the page" />
        <button type="submit">Save</button>
      </form>

      <ul className="demo-list">
        {notes.data.map(note => (
          <li key={note.id}>
            <span>
              {note.text}
              <small>{new Date(note.createdAt).toLocaleString()}</small>
            </span>
            <button type="button" onClick={() => remove(note)} aria-label="Remove note">

            </button>
          </li>
        ))}
      </ul>

      <p className="demo-footer">
        {notes.data.length} note(s) stored in IndexedDB.{" "}
        {notes.data.length > 0 && (
          <button type="button" className="link-button" onClick={clear}>
            Clear all
          </button>
        )}
      </p>
    </div>
  );
}

Run it locally

The playground is a small Vite app in examples/playground. To run it on your machine:

bash
git clone https://github.com/Agrejus/routier.git
cd routier
npm ci
npm run build            # the playground imports the built packages
cd docs
npm run playground:dev   # http://localhost:5220

Use an example in your own project

Each example is a single file. Install the packages it imports, then copy the file into any TypeScript project (React examples need a React app, such as one created with npm create vite@latest -- --template react-ts):

bash
npm install @routier/core @routier/datastore @routier/memory-plugin
npm install @routier/react           # React examples
npm install @routier/dexie-plugin    # IndexedDB example

Continue with Installation and the Quick Start.

Released under the MIT License.