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.
Examples
Schemas & CRUD
Define a schema, then add, query, update, and remove entities. Run it →
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 →
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 →
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 →
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 →
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:
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:5220Use 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):
npm install @routier/core @routier/datastore @routier/memory-plugin
npm install @routier/react # React examples
npm install @routier/dexie-plugin # IndexedDB exampleContinue with Installation and the Quick Start.