Step 4: React UI
import { Aside } from ‘@astrojs/starlight/components’;
Create the Routes Directory
Section titled “Create the Routes Directory”mkdir -p src/features/todos/routesThe Todos Page
Section titled “The Todos Page”Create src/features/todos/routes/todos._index.tsx:
import { useState } from 'react';import { trpc } from '@/trpc/client';import type { Todo } from '../todos.schema';
export default function TodosPage() { const utils = trpc.useUtils(); const [newTitle, setNewTitle] = useState('');
// Fetch todos — re-runs automatically after mutations const { data: todos, isLoading, error } = trpc.todos.list.useQuery();
// Mutations — each one invalidates the list on success const createTodo = trpc.todos.create.useMutation({ onSuccess: () => { utils.todos.list.invalidate(); setNewTitle(''); }, });
const updateTodo = trpc.todos.update.useMutation({ onSuccess: () => utils.todos.list.invalidate(), });
const deleteTodo = trpc.todos.delete.useMutation({ onSuccess: () => utils.todos.list.invalidate(), });
// ── Submit new todo ────────────────────────────────────────────────────────
function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!newTitle.trim()) return; createTodo.mutate({ title: newTitle.trim() }); }
// ── Render ─────────────────────────────────────────────────────────────────
if (isLoading) { return ( <div className="flex items-center justify-center min-h-64"> <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" /> </div> ); }
if (error) { return ( <div className="p-6 max-w-xl mx-auto"> <div className="bg-destructive/10 text-destructive p-4 rounded-lg"> <p className="font-medium">Error loading todos</p> <p className="text-sm mt-1">{error.message}</p> </div> </div> ); }
return ( <div className="p-6 max-w-xl mx-auto"> <h1 className="text-2xl font-bold mb-6">My Todos</h1>
{/* ── Add todo form ──────────────────────────────────────────────── */} <form onSubmit={handleSubmit} className="flex gap-2 mb-6"> <input type="text" value={newTitle} onChange={(e) => setNewTitle(e.target.value)} placeholder="What needs to be done?" className="flex-1 px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring" disabled={createTodo.isPending} /> <button type="submit" disabled={!newTitle.trim() || createTodo.isPending} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" > {createTodo.isPending ? 'Adding...' : 'Add'} </button> </form>
{/* ── Todo list ─────────────────────────────────────────────────── */} {!todos?.length ? ( <div className="text-center py-12 text-muted-foreground"> <p className="text-lg">No todos yet.</p> <p className="text-sm mt-1">Add one above to get started.</p> </div> ) : ( <ul className="space-y-2"> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} onToggle={(id, completed) => updateTodo.mutate({ id, data: { completed } }) } onDelete={(id) => deleteTodo.mutate({ id })} isUpdating={updateTodo.isPending} isDeleting={deleteTodo.isPending && deleteTodo.variables?.id === todo.id} /> ))} </ul> )}
{/* ── Stats ─────────────────────────────────────────────────────── */} {todos && todos.length > 0 && ( <p className="mt-4 text-sm text-muted-foreground text-right"> {todos.filter((t) => t.completed).length} / {todos.length} completed </p> )} </div> );}
// ── TodoItem ──────────────────────────────────────────────────────────────────
type TodoItemProps = { todo: Todo; onToggle: (id: string, completed: boolean) => void; onDelete: (id: string) => void; isUpdating: boolean; isDeleting: boolean;};
function TodoItem({ todo, onToggle, onDelete, isUpdating, isDeleting }: TodoItemProps) { return ( <li className="flex items-center gap-3 p-3 bg-card border border-border rounded-lg hover:bg-accent/5 transition-colors group"> {/* Checkbox */} <input type="checkbox" checked={todo.completed} onChange={(e) => onToggle(todo.id, e.target.checked)} disabled={isUpdating} className="h-4 w-4 rounded border-input accent-primary cursor-pointer" />
{/* Title */} <span className={`flex-1 text-sm ${ todo.completed ? 'line-through text-muted-foreground' : 'text-foreground' }`} > {todo.title} </span>
{/* Delete button */} <button onClick={() => onDelete(todo.id)} disabled={isDeleting} className="opacity-0 group-hover:opacity-100 p-1 text-muted-foreground hover:text-destructive transition-all disabled:opacity-50" aria-label="Delete todo" > {isDeleting ? ( <span className="text-xs">...</span> ) : ( <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg> )} </button> </li> );}How tRPC Hooks Work
Section titled “How tRPC Hooks Work”The hooks come from the trpc client, which is pre-wired to your server-side router. The types flow end-to-end without any manual type definitions:
// The return type is inferred from your TodosService.list() return typeconst { data: todos } = trpc.todos.list.useQuery();// ^-- Todo[] | undefined — fully typed, no manual annotation needed
// Mutation input is validated against your createTodoSchemacreateTodo.mutate({ title: 'Buy milk' });// ^-- TypeScript error if you pass the wrong shapeAfter a mutation succeeds, utils.todos.list.invalidate() tells React Query to refetch the list. The UI updates automatically.
Add a Navigation Link
Section titled “Add a Navigation Link”To link to the todos page from your app’s navigation, find your root layout (typically src/routes/root.tsx or a shared navigation component) and add a link:
import { Link } from 'react-router';
// Inside your nav component:<Link to="/todos" className="text-sm font-medium hover:underline"> My Todos</Link>Protect the Route
Section titled “Protect the Route”The /todos route should only be accessible to signed-in users. CruzJS provides a requireAuth loader helper:
import { requireAuth } from '@cruzjs/core/auth/utils.server';import type { LoaderFunctionArgs } from 'react-router';
// Add this loader to redirect unauthenticated users to /auth/loginexport async function loader({ request }: LoaderFunctionArgs) { return requireAuth(request);}
export default function TodosPage() { // ... component as above}If an unauthenticated user visits /todos, they are redirected to /auth/login and returned here after logging in.
Try It Out
Section titled “Try It Out”With the dev server running, visit http://localhost:5000/todos (after signing in).
You should be able to:
- Type a task and press Add
- Check a task to mark it complete (strikethrough)
- Hover a task and click the trash icon to delete it
- See the completion counter update in real time
Next: Deploy to Cloudflare →