Skip to main content

The complete app

Let’s put everything together. This is a complete user management app — the same patterns you’ve learned across the last several lessons, wired together into one working application.
This walkthrough is a JS-first teaching version of the patterns. The companion repo is more production-structured: backend routes live in backend/routers/users.py, models in backend/models.py, the web client is TypeScript (.ts/.tsx with frontend/src/types.ts and frontend/src/api/users.ts), and there’s also a mobile/ Expo app using the same FastAPI API.
my-fullstack-app/
├── backend/
│   └── main.py              # FastAPI — all endpoints
├── frontend/
│   └── src/
│       ├── api/
│       │   ├── client.js     # Shared fetch helper
│       │   └── users.js      # User CRUD functions
│       ├── components/
│       │   ├── CreateUserForm.jsx
│       │   ├── EditUserForm.jsx
│       │   └── UserCard.jsx
│       ├── App.jsx           # Main page component
│       └── main.jsx          # Entry point

Backend — the full API

The companion repo uses UserCreate for both create and update. This walkthrough shows a separate UserUpdate model to demonstrate a common partial-update pattern you’ll likely adopt later.
# backend/main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import os

app = FastAPI()

ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Models ──────────────────────────────────────

class UserCreate(BaseModel):
    name: str
    email: str

class UserUpdate(BaseModel):
    name: str | None = None
    email: str | None = None

# ── In-memory storage (use a real DB in production) ──

users = [
    {"id": 1, "name": "Sarah Chen", "email": "sarah@example.com"},
    {"id": 2, "name": "John Park", "email": "john@example.com"},
]
next_id = 3

def find_user(user_id: int):
    return next((u for u in users if u["id"] == user_id), None)

# ── Endpoints ───────────────────────────────────

@app.get("/api/users")
def get_users():
    return users

@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    user = find_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.post("/api/users", status_code=201)
def create_user(data: UserCreate):
    global next_id
    new_user = {"id": next_id, "name": data.name, "email": data.email}
    users.append(new_user)
    next_id += 1
    return new_user

@app.put("/api/users/{user_id}")
def update_user(user_id: int, data: UserUpdate):
    user = find_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    if data.name is not None:
        user["name"] = data.name
    if data.email is not None:
        user["email"] = data.email
    return user

@app.delete("/api/users/{user_id}", status_code=204)
def delete_user(user_id: int):
    user = find_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    users.remove(user)
Five endpoints. Five HTTP methods. That’s the entire backend for a CRUD app.

Frontend — the API client

// frontend/src/api/client.js
const API_URL = import.meta.env.VITE_API_BASE_URL;

export async function apiClient(endpoint, options = {}) {
  const response = await fetch(`${API_URL}${endpoint}`, {
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
    ...options,
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.detail || `HTTP ${response.status}`);
  }

  if (response.status === 204) return null;
  return response.json();
}
// frontend/src/api/users.js
import { apiClient } from './client';

export const getUsers    = ()         => apiClient("/api/users");
export const getUser     = (id)       => apiClient(`/api/users/${id}`);
export const createUser  = (data)     => apiClient("/api/users", { method: "POST", body: JSON.stringify(data) });
export const updateUser  = (id, data) => apiClient(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(data) });
export const deleteUser  = (id)       => apiClient(`/api/users/${id}`, { method: "DELETE" });

Frontend — the components

// frontend/src/components/CreateUserForm.jsx
import { useState } from 'react';

export default function CreateUserForm({ onSubmit }) {
  const [formData, setFormData] = useState({ name: "", email: "" });
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  function handleChange(e) {
    setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    setError(null);
    try {
      await onSubmit(formData);
      setFormData({ name: "", email: "" });
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} placeholder="Name" required />
      <input name="email" type="email" value={formData.email} onChange={handleChange} placeholder="Email" required />
      {error && <p className="error">{error}</p>}
      <button disabled={submitting}>{submitting ? "Adding..." : "Add User"}</button>
    </form>
  );
}
// frontend/src/components/EditUserForm.jsx
import { useState } from 'react';

export default function EditUserForm({ user, onSave, onCancel }) {
  const [formData, setFormData] = useState({ name: user.name, email: user.email });
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);

  function handleChange(e) {
    setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setSaving(true);
    setError(null);
    try {
      await onSave(formData);
    } catch (err) {
      setError(err.message);
      setSaving(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} required />
      <input name="email" type="email" value={formData.email} onChange={handleChange} required />
      {error && <p className="error">{error}</p>}
      <button disabled={saving}>{saving ? "Saving..." : "Save"}</button>
      <button type="button" onClick={onCancel}>Cancel</button>
    </form>
  );
}
// frontend/src/components/UserCard.jsx
import { useState } from 'react';
import EditUserForm from './EditUserForm';

export default function UserCard({ user, onUpdate, onDelete }) {
  const [editing, setEditing] = useState(false);
  const [deleting, setDeleting] = useState(false);

  async function handleSave(formData) {
    const updated = await onUpdate(user.id, formData);
    setEditing(false);
    return updated;
  }

  async function handleDelete() {
    if (!window.confirm(`Delete ${user.name}?`)) return;
    setDeleting(true);
    try {
      await onDelete(user.id);
    } catch {
      setDeleting(false);
    }
  }

  if (editing) {
    return <EditUserForm user={user} onSave={handleSave} onCancel={() => setEditing(false)} />;
  }

  return (
    <div className="user-card">
      <strong>{user.name}</strong>{user.email}
      <button onClick={() => setEditing(true)}>Edit</button>
      <button onClick={handleDelete} disabled={deleting}>
        {deleting ? "..." : "Delete"}
      </button>
    </div>
  );
}

Frontend — the main page

// frontend/src/App.jsx
import { useState, useEffect } from 'react';
import { getUsers, createUser, updateUser, deleteUser } from './api/users';
import CreateUserForm from './components/CreateUserForm';
import UserCard from './components/UserCard';

export default function App() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    getUsers()
      .then(data => setUsers(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  async function handleCreate(formData) {
    const newUser = await createUser(formData);
    setUsers(prev => [...prev, newUser]);
  }

  async function handleUpdate(userId, formData) {
    const updated = await updateUser(userId, formData);
    setUsers(prev => prev.map(u => u.id === userId ? updated : u));
    return updated;
  }

  async function handleDelete(userId) {
    await deleteUser(userId);
    setUsers(prev => prev.filter(u => u.id !== userId));
  }

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      <h1>User Management</h1>

      <h2>Add User</h2>
      <CreateUserForm onSubmit={handleCreate} />

      <h2>Users ({users.length})</h2>
      {users.length === 0 ? (
        <p>No users yet. Add one above!</p>
      ) : (
        users.map(user => (
          <UserCard
            key={user.id}
            user={user}
            onUpdate={handleUpdate}
            onDelete={handleDelete}
          />
        ))
      )}
    </div>
  );
}

Tracing the data flow

Let’s trace what happens when a user clicks “Add User”:
1

User fills form and submits

CreateUserForm calls onSubmit({ name: "Maria Lopez", email: "maria@example.com" })
2

App.jsx handles the create

handleCreate calls createUser(formData) from the API client
3

API client sends the request

fetch("http://localhost:8000/api/users", { method: "POST", body: ... })
4

FastAPI receives and validates

Pydantic validates the data. If valid, creates the user and returns { id: 3, name: "Maria Lopez", email: "maria@example.com" } with status 201
5

API client parses the response

response.json() returns the new user object back to handleCreate
6

State updates, React re-renders

setUsers(prev => [...prev, newUser]) adds the user. React re-renders the list. Maria appears on screen.
Every CRUD operation follows this same flow: Component → API client → fetch() → FastAPI → response → state update → re-render.
When debugging, trace the flow step by step. Add console.log at each stage to see where data gets lost or transformed incorrectly. The Network tab in DevTools shows the actual HTTP requests and responses.

What’s next?

You’ve seen the complete app. Now let’s polish it — handling errors gracefully across the entire stack.

Error handling across the stack

Handle errors consistently from FastAPI to React for a great user experience