Documentation
Build a live React Todo application with Fireclass, Firebase client Firestore, validation, and subscriptions.
This guide builds a Todo interface that creates, updates, and deletes Firestore
documents while useQuery keeps every open tab synchronized.
Example application
Browse the Fireclass React example for the generated Vite project structure and configuration.
src/
lib/
firebase.ts
fireclass.ts
models/
todo.ts
App.tsx
main.tsx
.env.local
fireclass.json
tsconfig.app.jsonStart from the React setup, then verify the project:
npx fireclass doctorCreate a Firebase Web App and add its public client configuration to Vite's environment file.
VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=your-sender-id
VITE_FIREBASE_APP_ID=your-app-idThese values identify the web app; they are not Admin credentials. Security comes from Firebase Authentication, App Check, and Firestore Security Rules.
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const app = initializeApp({
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
});
export const db = getFirestore(app);import "reflect-metadata";
import { createFireclass } from "@dharayush7/fireclass-react";
import { db } from "./firebase";
export const {
BaseModel,
adapter,
useQuery,
useDoc,
} = createFireclass(db);import { Collection } from "@dharayush7/fireclass-react";
import { Type } from "class-transformer";
import {
IsBoolean,
IsDate,
IsOptional,
IsString,
Length,
} from "class-validator";
import { BaseModel } from "../lib/fireclass";
@Collection("todos")
export class Todo extends BaseModel<Todo> {
@IsString()
@Length(1, 120)
title!: string;
@IsBoolean()
done!: boolean;
@IsOptional()
@IsDate()
@Type(() => Date)
createdAt?: Date;
constructor(data?: Partial<Todo>) {
super(data);
Object.assign(this, data);
}
}Load reflect-metadata before the application imports decorated models.
import "reflect-metadata";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);Ensure the application TypeScript configuration contains:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}useQuery opens an onSnapshot listener. Model writes update Firestore, and
the listener replaces the local list with hydrated Todo instances.
import { ValidationFailedError } from "@dharayush7/fireclass-react";
import { useState } from "react";
import { useQuery } from "./lib/fireclass";
import { Todo } from "./models/todo";
function validationMessage(error: ValidationFailedError): string {
return error.errors
.flatMap((issue) => Object.values(issue.constraints ?? {}))
.join(", ");
}
export function App() {
const { data: todos, loading, error } = useQuery(Todo, {
orderBy: { createdAt: "desc" },
limit: 100,
});
const [title, setTitle] = useState("");
const [mutationError, setMutationError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function addTodo() {
setMutationError(null);
setSaving(true);
try {
await new Todo({
title: title.trim(),
done: false,
createdAt: new Date(),
}).save();
setTitle("");
} catch (error) {
setMutationError(
error instanceof ValidationFailedError
? validationMessage(error)
: error instanceof Error
? error.message
: "Could not save the todo.",
);
} finally {
setSaving(false);
}
}
async function toggle(todo: Todo) {
setMutationError(null);
todo.done = !todo.done;
try {
await todo.save();
} catch (error) {
todo.done = !todo.done;
setMutationError(
error instanceof Error ? error.message : "Could not update the todo.",
);
}
}
async function remove(todo: Todo) {
setMutationError(null);
try {
await todo.delete();
} catch (error) {
setMutationError(
error instanceof Error ? error.message : "Could not delete the todo.",
);
}
}
return (
<main>
<h1>Realtime Todos</h1>
<form
onSubmit={(event) => {
event.preventDefault();
void addTodo();
}}
>
<label htmlFor="title">New todo</label>
<input
id="title"
value={title}
maxLength={120}
onChange={(event) => setTitle(event.target.value)}
/>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Add"}
</button>
</form>
{mutationError && <p role="alert">{mutationError}</p>}
{loading && <p>Loading...</p>}
{error && <p role="alert">{error.message}</p>}
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => void toggle(todo)}
/>
{todo.title}
</label>
<button type="button" onClick={() => void remove(todo)}>
Delete
</button>
</li>
))}
</ul>
{!loading && todos.length === 0 && <p>No todos yet.</p>}
</main>
);
}Open the application in two browser tabs. Adding, toggling, or deleting in one tab updates the other without a manual fetch.
Use useDoc when a route or panel owns one document id.
import { useDoc } from "./lib/fireclass";
function TodoDetails({ id }: { id?: string }) {
const { data: todo, loading, error } = useDoc(Todo, id);
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">{error.message}</p>;
if (!todo) return <p>Todo not found.</p>;
return <h2>{todo.title}</h2>;
}Passing undefined skips the subscription, which is useful before a router or
authentication layer has produced an id.
Client configuration is not authorization
Every model operation runs as the current Firebase client. Production rules
must restrict todos to the correct authenticated users and validate writable
fields. Do not ship open test-mode rules.
For local development, prefer the Firebase emulator. For a real project, add Firebase Authentication before allowing writes and test Security Rules with the Rules emulator.
useQuery automatically unsubscribes on unmount and query change.error.Date, DocumentSnapshot, and other non-JSON filter or cursor values in
useQuery options.See the React API reference for exact hook state behavior.