Skip to content

Step 4: React UI

import { Aside } from ‘@astrojs/starlight/components’;

Terminal window
mkdir -p src/features/todos/routes

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>
);
}

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 type
const { data: todos } = trpc.todos.list.useQuery();
// ^-- Todo[] | undefined — fully typed, no manual annotation needed
// Mutation input is validated against your createTodoSchema
createTodo.mutate({ title: 'Buy milk' });
// ^-- TypeScript error if you pass the wrong shape

After a mutation succeeds, utils.todos.list.invalidate() tells React Query to refetch the list. The UI updates automatically.

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>

The /todos route should only be accessible to signed-in users. CruzJS provides a requireAuth loader helper:

src/features/todos/routes/todos._index.tsx
import { requireAuth } from '@cruzjs/core/auth/utils.server';
import type { LoaderFunctionArgs } from 'react-router';
// Add this loader to redirect unauthenticated users to /auth/login
export 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.

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 →