Scaffold a React app, log in through a real form, and do full CRUD without ever opening /docs again — then close the three gaps nobody builds: loading, empty, and error.
So by the end of Phase 3 my app had a memory [a database], a mouth [HTTP endpoints], and a lock on the door [auth]. It knew who you were and refused to show you anyone else's expenses. Genuinely satisfying. There was just one small, embarrassing detail: the only human who could actually use it was me, poking at /docs. My "app" was a Swagger page and a lot of faith. No screen, no login box, nothing a normal person could look at. It had everything except a face.
Phase 4 gives it one.
A confession before we start: I changed the plan
If you read the end of my Phase 3 write-up, I promised Phase 4 would be filtering, sorting, and pagination — making the backend list usable at scale. I changed my mind. I went with frontend.
My reasoning, honestly: I've been living in Python for four phases and I've never touched React. Building the UI now forces me to re-meet my own auth, tokens, CORS, and endpoints from the client's side of the wire — which is the best way to find out whether I actually understood them. The known cost, which I'm accepting on purpose: GET /expenses is still unbounded, so when pagination lands later I'll have to come back and revise this list UI. Flagged, accepted, moving on.
Let's call it PHASE 4 — The Face:
- Scaffold a real React app with Vite
- Write my first component and actually understand JSX
- Build a login form that holds its own text [controlled inputs +
useState] - Call the backend from the browser — and get mugged by CORS
- Store the token, and reckon with where to store it
- Fetch and render the expense list [
useEffect] - Close the three gaps: loading, empty, error
- Wire up add / delete / edit — full CRUD from the UI
- Fix the bug that made login "do nothing until I refreshed"
First, the new world [everything here is different from the backend]
The backend runs on Python; you install libraries with pip and freeze them into requirements.txt. The frontend has an exact parallel universe, and learning the mapping made the whole thing click:
- Node.js is the JavaScript runtime — the "Python interpreter" of the frontend. [I'm on v22.]
-
npm is Node's package manager — this is
pip. [I'm on v11.] -
package.jsonisrequirements.txt's counterpart — except it updates itself automatically when you install. No manualfreezestep over here. One less habit to remember on this side of the fence. -
node_modules/isvenv/— enormous, machine-specific, and it must never touch git. -
Vite [French for "quick," said veet] is the tool that scaffolds the app and runs the hot-reloading dev server. It's the frontend's
uvicorn --reload.
Two forks in the road, two flagged shortcuts:
- Vite offered me TypeScript or plain JavaScript. I took plain JS. TypeScript is fantastic and production-common, but it's a second new language stacked on top of React, and one new language at a time is plenty.
[TypeScript = "know this exists," not now.] - It also asked which linter — Oxlint or ESLint. [A linter is a spell-checker for code: it reads your code without running it and flags likely bugs and sloppy style.] I picked ESLint because it's the industry default — when I hit an error and google it, ESLint results are everywhere. Oxlint is newer and faster but less documented.
[Oxlint = know this exists.]
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm run dev
That last command lit up a starter page at http://localhost:5173 with a clicky "count is 0" button — which, I later learned, is useState doing its thing. My first commit of the phase was that untouched scaffold, on its own: a clean "React works" checkpoint I could always fall back to before I started breaking things.
Habit that carried over: before committing, I checked git wasn't about to swallow
node_modules. Turns out Vite drops its own.gitignoreinsidefrontend/— and yes, git supports multiple.gitignorefiles, one per folder. I didn't take that on faith [never, with git]; I rangit check-ignore frontend/node_modulesand it echoed the path back, which is git saying "yep, ignoring that." Verify, don't assume.
Step 1: My first component [JSX has three rules that bite]
A React component is deceptively simple: it's just a JavaScript function, with a Capitalized name, that returns markup. That markup is JSX — it looks like HTML but it's actually JavaScript wearing a costume. I overwrote Vite's demo App.jsx with my own [safe: it's boilerplate, and I'd just committed, so git checkout would bring it back instantly]:
function App() {
return (
<div>
<h1>Smart Expense Manager</h1>
<p>Frontend is alive.</p>
</div>
)
}
export default App
Three JSX rules that trip everyone, now filed away:
- One single root element. You can't return two side-by-side tags; wrap them in a parent. [React will yell at you.]
-
className, notclass— becauseclassis a reserved word in JavaScript. -
{ }to drop a JS value into markup —{2 + 2}renders4.
export default App is what lets main.jsx do import App from './App.jsx' — the JS version of exposing a name so another module can import it. I saved the file, and the browser instantly swapped to my text without a refresh. That's hot reload, and it's addictive.
Step 2: A login form that holds its own text [useState]
My instinct was to store what the user types in a normal variable — let email = ''. Wrong, and here's the precise why: React only updates the screen when it re-renders, and a plain variable changing doesn't trigger one. The box would go stale.
The fix is useState, a hook — a special function that lets a component remember a value across re-renders and re-render when that value changes.
import { useState } from 'react'
function App() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
function handleSubmit(event) {
event.preventDefault()
console.log('Logging in with:', email, password)
}
return (
<div>
<h1>Smart Expense Manager</h1>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button type="submit">Log in</button>
</form>
</div>
)
}
New ideas, in plain language:
-
const [email, setEmail] = useState('')returns a pair — the current value and a setter. That[...]unpacking is array destructuring. The iron rule: never reassignemaildirectly — always callsetEmail(...). The setter is what tells React "this changed, re-render." -
Controlled input — the two-way loop that is the React form pattern:
value={email}makes the box always display state,onChangepushes every keystroke back into state. State is the single source of truth; the input just mirrors it. -
import { useState }with braces is a named import;import Appwithout braces is a default import. The braces are the tell.
And the one that will get me someday:
Gotcha #1 —
event.preventDefault()is not optional. By default, submitting an HTML form makes the browser reload the entire page [a genuine 1990s behavior]. In a React app that wipes all your state and blanks the screen.preventDefault()cancels it so I stay in control. I know for a fact I'll forget this line one day and watch my page flash — but now I'll know exactly why.
I opened DevTools, typed in both boxes [text appeared as I typed — the loop works], clicked Log in, and watched Logging in with: ... print to the console with no page reload. First real interaction, done.
Step 3: Call the backend — and get mugged by CORS
Now the browser had to actually talk to FastAPI. Two new concepts collided here.
First, async / await and fetch. Network calls take time, and JavaScript can't freeze the browser waiting. So fetch [the browser's built-in HTTP client — the JS version of Python's requests] returns a Promise, an "I'll have your answer later" placeholder. await pauses this function until it resolves, and any function using await must be marked async [same word as FastAPI's async def].
Second — and this is the single biggest gotcha of the whole phase:
Gotcha #2 — the login endpoint is the odd one out: it wants FORM data, not JSON. My
/auth/loginusesOAuth2PasswordRequestForm, which readsapplication/x-www-form-urlencoded, not JSON. Send it JSON and you get a baffling422. So I built the body withURLSearchParamsand set the matchingContent-Type. Bank this: the OAuth2 login endpoint is form-encoded; every other endpoint in my API speaks JSON. Mixing these two up is the classic source of 422s.
async function handleSubmit(event) {
event.preventDefault()
const formBody = new URLSearchParams()
formBody.append('username', email) // OAuth2 calls the field "username" — I put my email there
formBody.append('password', password)
const response = await fetch('http://localhost:8000/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody,
})
const data = await response.json()
console.log('Status:', response.status, 'Body:', data)
}
My mentor predicted this would fail, and it did — with a big red wall:
Gotcha #3 — CORS, the thing my roadmap warned trips up everyone.
Access to fetch at 'http://localhost:8000/auth/login' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header...My frontend runs on port 5173, my backend on 8000 — different port means the browser considers them different origins, and it refused to let my code read the response.
CORS [Cross-Origin Resource Sharing] is a browser security feature, not a bug in my code. The scenario it prevents: you're logged into your bank in one tab, you visit a sketchy site in another, and that site's JavaScript quietly fires requests at your bank using your session. To stop that, the browser won't let JS on origin A read a response from origin B unless server B explicitly says it allows A. Server B says so with an Access-Control-Allow-Origin header — which my FastAPI wasn't sending. Getting the error actually meant my request was leaving the browser correctly; the backend just hadn't granted permission yet.
The fix lives on the backend, via FastAPI's CORSMiddleware [a middleware is code that wraps every request/response — perfect for stamping a header onto everything]. Bonus: it ships inside FastAPI, so no install, no requirements.txt change this time.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # the whitelist — explicit, never "*"
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Two decisions worth stating:
-
Never
allow_origins=["*"]. It defeats the point, and the browser flat-out forbids"*"together withallow_credentials=True. Always list explicit origins.[Flagged shortcut: I hardcoded the dev URL. Production reads allowed origins from an env var and lists the real Vercel domain — that's my Phase 9 deploy task.] -
allow_headers=["*"]matters because I'm about to start sending anAuthorizationheader on every protected request.
uvicorn hot-reloaded, I clicked Log in again, and the CORS error was gone — replaced by a clean 401, because my dev database was freshly regenerated and had zero users. A 401 round-trip was a success: the browser-to-backend pipe now worked end to end. I registered a test user through /docs, typed the same credentials into my form, and got 200 with a glorious { access_token: "eyJ...", token_type: "bearer" }. My React app had fetched its own JWT.
Step 4: Store the token [the one real security decision this phase]
The token vanished the instant handleSubmit finished. I needed to keep it — every future request has to attach it, and a refresh shouldn't log me out. Enter localStorage: a tiny per-origin key-value store built into the browser, persists across reloads, dead-simple API [setItem, getItem, removeItem], values are always strings.
if (response.ok) {
const data = await response.json()
localStorage.setItem('token', data.access_token)
}
[response.ok is a boolean that's true for any 2xx — the clean way to ask "did this succeed?" instead of eyeballing status numbers.]
Here's where I put the security hat back on, because it genuinely matters:
localStorage is readable by any JavaScript running on my page. So if an attacker ever injects a script into my app — that's an XSS [cross-site scripting] attack — they can read the token straight out of storage and impersonate the user. That's the real risk, stated plainly.
-
What I did [flagged learning shortcut]: store the JWT in
localStorage. Simple, easy to inspect while learning, sidesteps cookie/CORS-credential complexity. -
What production does: store it in an
httpOnly,Secure,SameSitecookie.httpOnlymeans JavaScript literally cannot read it, so even a successful XSS can't steal it, and the browser attaches it automatically.[Know this exists. Not building it now.]
React's own defense is worth knowing too: it auto-escapes any text you render, which neutralizes most XSS by default. The danger zone is a prop literally named dangerouslySetInnerHTML — the scary name is the warning. Don't use it and you dodge the common trap.
I logged in, opened DevTools → Application → Local Storage, saw my token key sitting there, refreshed the page, and it survived. That persistence is the whole point.
Step 5: The expense list [useEffect, and the infinite-loop trap]
Reading data needs useEffect, and I'm glad I learned the trap before hitting it. A component function re-runs on every render. So you can't just drop a fetch in the component body — it'd fire on every render, hammer the API, and [if it sets state] spin into an infinite re-render loop.
useEffect lets you say "run this as a side effect, but only at specific times." The dependency array [second argument] controls when:
-
[]empty → run once, right after the component first mounts. Exactly what "fetch when the page loads" wants. -
[x]→ also re-run whenxchanges. - omitted entirely → run after every render. The infinite-loop trap. Don't.
One quirk: the effect function itself can't be async, so the pattern is to define an async function inside it and call it immediately.
import { useState, useEffect } from 'react'
function ExpenseList() {
const [expenses, setExpenses] = useState([])
useEffect(() => {
async function fetchExpenses() {
const token = localStorage.getItem('token')
const response = await fetch('http://localhost:8000/expenses', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await response.json()
setExpenses(data)
}
fetchExpenses()
}, [])
// ...
}
That `Bearer ${token}` is a template literal — backticks let you embed a variable with ${...}. It produces the exact string my /docs "Authorize" button sends, which is how the app proves who it is on every protected request. The fetch came back with a clean empty array [] — correct, since my test user had no expenses yet. Authentication working from the client side, first time.
Step 6: The three gaps nobody builds [loading, empty, error]
This is the heart of the phase. And it starts with a gotcha that burns everyone:
Gotcha #4 —
fetchdoes NOT throw on a 404 or 500. It only rejects when the network itself fails [server down, CORS block]. A401or500is, tofetch, a perfectly "successful" round-trip that happens to carry a bad status. So you have to checkresponse.okyourself and deliberately throw on a bad status. Coming from Python'srequests, this genuinely surprised me.
I handled both failure kinds with try / catch / finally [same shape as Python]. finally runs no matter what — the perfect home for setLoading(false), so a loading spinner can never get stuck on screen. And I rendered the states with early returns in strict priority order: loading → error → empty → list.
const [expenses, setExpenses] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchExpenses() {
const token = localStorage.getItem('token')
try {
const response = await fetch('http://localhost:8000/expenses', {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
const data = await response.json()
setExpenses(data)
} catch (err) {
setError('Could not load expenses. Please try again.')
console.error(err)
} finally {
setLoading(false)
}
}
fetchExpenses()
}, [])
The part tutorials skip — I tested the error gap on purpose. I hit Ctrl+C on the backend, refreshed the browser, and instead of a blank frozen page I got my red "Could not load expenses. Please try again." That's the adversarial habit surviving even without the GAN framing: don't just build the happy path, actively try to break it.
Then rendering the list with .map() — the array method that turns each expense object into an <li>. Every mapped element needs a key — a stable unique id so React can track rows across re-renders. Use the database id, never the array index [index breaks the moment the list reorders or an item is removed].
While wiring this, a small honest scope decision surfaced: my mentor's example row showed a category field. My Expense model doesn't have one. Adding it isn't a display tweak — it's a new column, a migration, and schema changes. That's literally what my roadmap's Phase 7 [AI categorization] is for. So I dropped it rather than scope-creep a clean foundation phase. [category → deferred to Phase 7.] My real ExpenseRead returns id, amount, description, spent_on, and timestamps — and a nice detail: because amount is a Decimal, Pydantic serializes it to a string on purpose, to protect money from float rounding.
Step 7: Full CRUD from the UI [add, delete, edit]
Add [POST]. Same JSON pattern I'll use everywhere except login: Content-Type: application/json and body: JSON.stringify({...}). On success, I clear the form and re-fetch the list so the new row appears — letting the server stay the single source of truth. To make that re-fetch reusable, I pulled fetchExpenses out of useEffect into the component body, so both the initial load and every post-write refresh could call it.
async function handleAdd(event) {
event.preventDefault()
const token = localStorage.getItem('token')
const response = await fetch('http://localhost:8000/expenses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ description, amount, spent_on: spentOn }),
})
if (response.ok) {
setDescription(''); setAmount(''); setSpentOn('')
fetchExpenses()
}
}
[A lovely bit of luck: an <input type="date"> gives a date-picker whose value is already a "YYYY-MM-DD" string — exactly what my Python date field wants, zero conversion.]
Adding the always-visible form forced a rendering upgrade. Early returns worked when the whole component was one state, but the form has to show even in the empty state. So I switched to inline conditional rendering with &&:
{loading && <p>Loading expenses…</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
{!loading && !error && expenses.length === 0 && <p>No expenses yet.</p>}
{!loading && !error && expenses.length > 0 && ( /* the list */ )}
The lesson worth banking: early-return when the entire component is one state; inline && when only part of the UI changes.
Gotcha #5 — one missing
!showed two contradictory states at once. For a while my app displayed "Could not load expenses" and "No expenses yet" simultaneously — which is impossible; those are supposed to be mutually exclusive. The cause was hilariously small: my empty-state guard was missing the!errorcheck. One character [!] fixed it. A whole render bug lived in a single missing symbol.
Delete [DELETE]. Two things I hadn't met:
Gotcha #6 — a 204 has no body, so don't parse one. My
DELETEreturns204 No Content— by definition, empty. Callingresponse.json()on it would throw. Just checkresponse.okand move on.Gotcha #7 — passing an argument to a handler needs an arrow wrapper. To delete a specific row I wrote
onClick={() => handleDelete(expense.id)}. If I'd writtenonClick={handleDelete(expense.id)}[no arrow], React would call it immediately during render — deleting everything the instant the list appeared. The arrow hands React a function to call later, on click. Rule of thumb I locked in: no args → pass the reference [onClick={handleLogout}]; need args → wrap in an arrow.
Edit [PATCH]. The biggest UI concept: per-row edit mode. I track which row is editing with a single editingId [null = nobody], and render with a ternary — "if this row is being edited, show inputs; otherwise show text + buttons." Each branch has to return one element, so I wrapped the siblings in a Fragment [<>...</>] — an invisible grouping tag that adds no extra <div> to the page.
PATCH is a partial update: my backend's ExpenseUpdate has all-optional fields, and it saves with payload.model_dump(exclude_unset=True) → setattr → db.commit() → db.refresh(). On success I close edit mode and re-fetch, same source-of-truth habit.
With edit working, I'd hit the phase's finish line: log in through the UI and do full create/read/update/delete without ever opening /docs.
Step 8: The bug that made login "do nothing until I refreshed"
This one deserves its own section because it taught me the deepest React lesson of the phase. My symptom: I'd log in, and nothing happened — until I refreshed the page, at which point it worked perfectly.
The cause, once I understood it, is fundamental:
Gotcha #8 — React does not watch
localStorage. My login handler wrote the token tolocalStorage, but React only re-renders when a piece of state changes. Writing to storage is completely invisible to it. And myExpenseListfetched only once, on mount — so on a fresh load it fired withBearer null, failed, and never ran again. Logging in stashed a token, but nothing told React anything had changed. A refresh "fixed" it only because it re-mounted the whole app after the token was already in storage.
The fix was my first taste of lifting state up: the token belongs in App [the parent], because App is what decides between "logged out" and "logged in." I put the token in React state, seeded from storage, and set it on login:
const [token, setToken] = useState(localStorage.getItem('token')) // survives refresh
// in the login success branch:
localStorage.setItem('token', data.access_token)
setToken(data.access_token) // <-- THE missing trigger. State change → re-render → instant switch.
// gate the whole UI on it:
if (token) {
return (
<div>
<h1>Smart Expense Manager</h1>
<button onClick={handleLogout}>Log out</button>
<ExpenseList />
</div>
)
}
// otherwise fall through to the login form
Logout is just login run backwards — clear both copies of the token:
function handleLogout() {
localStorage.removeItem('token') // so a refresh won't silently log me back in
setToken(null) // so the gate falls through to the login form
}
Two more real bugs fell out of this, and both are honest lessons:
-
The stray-render leak. After adding the gated view, logging out still showed the expense list — because an earlier
<ExpenseList />was still sitting in the logged-out return from a previous step. I'd added the new one without removing the old.ExpenseListshould appear in exactly one place. A quick file search [it should show up twice: the import + one render] caught it. Lesson: when you restructure, hunt down the leftovers. -
Incognito confused me for a minute. Logged in on a normal window, I opened incognito, logged in there, and the two windows behaved independently. That's correct —
localStorageis isolated per browser profile, so incognito has its own separate store. [Imagine if it didn't; that'd be a leak.] Two normal tabs share storage but not live React state, so one won't visually update until it refreshes.[Live cross-tab sync via the storage event = know this exists.]
Bonus: so how does clicking "Save" actually change the database?
This confused me, so I'll write down the model that fixed it: there are three separate programs, and none can touch the others' memory.
- My React app — JavaScript in the browser. It knows nothing about the database. All it can do is send HTTP requests.
-
My FastAPI backend — Python in the
uvicornterminal. The only program that talks to the database. -
SQLite — the
expenses.dbfile. It only ever hears from the backend.
So the Save button does not save to the database. It sends a PATCH note to the backend asking it to save. The backend checks my token, finds my row [scoped to my user id — my Phase 3 ownership filter riding along], sets the new values, and runs db.commit() — that single line is the actual write to disk. Then it replies 200, and my browser re-fetches so the screen matches what the database now says. Every operation in the app is that same shape: Create = POST + commit, Delete = DELETE + commit, Update = PATCH + commit, Read = GET [no commit — nothing changes]. Once that one round-trip clicked, the whole app made sense.
Stuff I want to remember [the honest takeaways]
- The frontend has an exact mirror of the backend's tools: Node = Python, npm = pip,
package.json= requirements.txt [but it auto-freezes],node_modules= venv, Vite = uvicorn. - A component is a Capitalized function returning JSX. JSX rules that bite: one root element,
classNamenotclass,{ }for JS values. -
useStatereturns a[value, setter]pair. Never reassign the value — always call the setter. The setter is the only thing that triggers a re-render. - React only re-renders on state change. It does not watch
localStorage. This one fact caused my nastiest bug. -
Controlled inputs [
value+onChange] make state the single source of truth for a form. -
event.preventDefault()stops the browser's default full-page reload on form submit. You will forget this once. -
CORS is a browser security feature, not a bug in your code. Different port = different origin; the fix is
CORSMiddlewareon the backend, with an explicit origin whitelist [never"*"with credentials]. - The OAuth2 login endpoint is the odd one out — it wants form-encoded data. Everything else speaks JSON. Mixing them up is the classic 422.
-
fetchdoes NOT throw on 4xx/5xx. Checkresponse.okyourself and throw deliberately. Usetry/catch/finally, and putsetLoading(false)infinallyso it can never get stuck. - Build the happy path, then close the three gaps: loading, empty, error — and actually test the error state by killing the backend.
-
.map()renders lists; every item needs a stablekey— use the DB id, never the array index. -
&&for show-or-nothing; ternary? :for A-or-B; Fragments<>...</>to group siblings without an extra<div>. - Passing args to a handler needs an arrow wrapper [
() => fn(id)] or it fires during render. No args → pass the reference. - A
204response has no body — don't call.json()on it. -
JWT in
localStorageis a flagged shortcut [readable by any JS → XSS risk]. Production uses anhttpOnly Secure SameSitecookie. React auto-escapes text; avoiddangerouslySetInnerHTML. - The Save button doesn't save — it asks the backend to.
db.commit()is the real write. The browser, the backend, and the database are three separate programs passing notes. - When you restructure, hunt the leftovers — a stray render leaked my list into the logged-out view.
- Commit at every green checkpoint, with a subject + a "why." Small and often beats one heroic commit.
Next up: Phase 5. The app now has a memory, a mouth, a lock, and — finally — a face a human can log into and use. But that face is honest to a fault: it's a raw list with no styling, no summaries, and a login that just... stops working after 30 minutes with a confusing error [my JWTs expire, and I don't handle it yet]. Phase 5 is where the face gets some expression — a dashboard, spending insights, and the polish that turns "it works" into "I'd actually use this." That, and I owe this app an "expired-token → send me back to login" fix I flagged and walked away from. See you there.
Top comments (0)