DEV Community

Azhar Alvi
Azhar Alvi

Posted on

Phase 4 [Part 2] : The Deviation

Kill the 30-minute dead-end where an expired token traps you on a broken screen, break one bloated component into smaller ones that each own their job, and give the login and app shell their first real coat of paint — the two debts I flagged at the end of Part 1, plus the refactor that made fixing them possible.

At the close of Part 1, my app finally had a face: I could log in through a real form and do full CRUD without ever opening /docs. Genuinely a milestone. But I ended that write-up with two honest confessions taped to the fridge. One: the login silently dies after 30 minutes — my JWTs expire, and when they do, the app just shows a confusing error and strands you, still pretending you're logged in. Two: it looked like a 1998 government form. A raw list, no styling, no polish.

I said I'd deal with both in "Phase 5." I changed my mind again.

A second confession: I pulled two Phase-5 jobs forward [and picked up a third I didn't plan]

Building a dashboard on top of a frontend that (a) traps users on a dead session, (b) crams everything into two god-components, and (c) is visually unusable felt like laying a nice floor over a cracked foundation. So before any new features, I spent a session paying rent on the foundation.

And here's the honest part: only one of these three jobs was on my list. The expired-token fix, yes — I'd flagged it. But chasing that bug is what dragged the props refactor in behind it, because the fix literally couldn't be written cleanly until I'd split my components up. One debt exposed another. That happens a lot, I'm learning.

Let's call it PHASE 4, PART 2 — Paying the Rent:

  • Reproduce the expired-token dead-end on purpose [without waiting 30 minutes]
  • Detect a 401 on any request and force a clean re-login
  • Learn props for real — by passing a function down so a child can trigger the parent's logout
  • Split App and ExpenseList into <LoginForm> and <AddExpenseForm>
  • Install Tailwind v4 [which is nothing like the tutorials you'll find]
  • Style the login into a centered card and the app into a dashboard shell
  • Make an honest call about a lint warning instead of cargo-culting a "fix"

Debt #1: the 30-minute dead-end [expired-token hardening]

Here's the flaw, stated plainly. My login issues a JWT with a 30-minute self-destruct baked in [the exp claim — Phase 3 me set that on purpose]. After 30 minutes the token is dead, and my FastAPI backend will reject any authenticated request — list, add, edit, delete — with a 401 Unauthorized. That's not a bug; that's the security feature working exactly as designed.

The bug was my reaction to it. My fetchExpenses caught any bad response as a generic "Could not load expenses," and my mutation handlers just console.error-ed the status. Meanwhile App still had a truthy token in state, so it kept rendering the logged-in view. The token was dead, but the app cheerfully pretended I was alive — an error message, a Logout button, and no automatic way out. A dead-end.

First move, and this is the adversarial habit surviving without the GAN framing: I reproduced the failure on purpose instead of waiting half an hour. DevTools → Application → Local Storage → double-click the token value → jam a few junk characters onto the end → hit an endpoint. An invalid token gets the exact same 401 an expired one would. Instant, repeatable bug. Watching it fail on demand is what made the fix obvious.

Then I hit the wall that turned this into a bigger job than expected:

The logout logic lived in the wrong place to fix this. My handleLogout [localStorage.removeItem('token') + setToken(null)] sits in App, the parent. But the 401 happens inside ExpenseList, the child. A child component cannot reach up and grab its parent's functions on its own. So how does the child tell the parent "the session is dead, log us out"? That question is what forced me to finally learn props — see the next section. The short version of the answer: the parent hands the child a function to call.

With that wired (details below), the fix inside each authed request was tiny and identical — check for 401 first, and if you see it, bail:

if (response.status === 401) {
  onAuthError();   // a function App passed down; it runs handleLogout
  return;
}
Enter fullscreen mode Exit fullscreen mode

Gotcha #9 — a 401 is a subset of "not ok," so you have to catch it before your generic error branch. My old code did if (!response.ok) throw .... If I'd let that run first, every expired token would just show "Could not load expenses" and never log me out. By checking response.status === 401 first and return-ing, I short-circuit: onAuthError() flips token to null up in App, App stops rendering ExpenseList, and the login screen takes over. Order matters. Specific case before general case.

I dropped that same three-line guard into all four authed calls — fetchExpenses, handleAdd, handleDelete, handleUpdate — and while I was crawling through handleDelete, I found a landmine:

Gotcha #10 — a one-letter case typo that "works" only by luck. My delete URL read http://Localhost:8000 — capital L. It had been working the whole time because Windows treats hostnames case-insensitively, so Localhost resolves fine on my machine. The moment I deploy the backend to a Linux box [Phase 9], that could bite. Fixed it to lowercase localhost now, while I was in the neighborhood. Lesson: "it works on my machine" and "it's correct" are not the same sentence.

The payoff: I re-ran my sabotage — logged in, corrupted the token, triggered a request — and instead of the stuck error screen, the app bounced me straight back to the login form. Tested it from a reload and from the add button, to prove the guard fires from more than one place. The session can no longer lie to me.

The cleanup I didn't plan on: props [the refactor]

Remember the wall above — the child couldn't reach the parent's logout. The clean answer to that is the single most important React concept I'd been avoiding: props.

Props are how a parent hands things down to a child — data, config, or [the part that unlocks everything] functions. And the mirror image is the pattern that fixed my bug: a child reports an event up by calling a function its parent passed down. That's it. Data flows down; events flow up as function calls. My onAuthError was my very first one — App handed ExpenseList its handleLogout, renamed onAuthError for clarity, and the child calls it when it smells a dead session.

// In App: hand the function down
<ExpenseList onAuthError={handleLogout} />
Enter fullscreen mode Exit fullscreen mode
// In ExpenseList: receive it
function ExpenseList({ onAuthError }) {
Enter fullscreen mode Exit fullscreen mode

That { onAuthError } in the function signature stopped me for a second, so I'll pin down what it is:

{ } in a component's arguments is object destructuring — the cousin of the [ ] I already knew from useState. React always calls my component with one argument: a single object holding all its props. { onAuthError } reaches into that object and pulls out the onAuthError key by name. Contrast: useState gives me const [value, setter] — array destructuring, which unpacks by position [that's why order matters there]. Objects unpack by name, so the name inside the braces must exactly match the prop I passed. A typo like { onAuthErr } just silently hands me undefined.

Once I understood props, the mess I'd been living in became obvious: my login form was welded inside App, and my add-expense form was welded inside ExpenseList. Two components doing three jobs each. So I split them out, and I picked a deliberate design rule for both:

Each form owns its own state and does its own API call, then reports the result up through a callback prop. The parent stays thin and owns only what's genuinely shared. App doesn't care about my email or password keystrokes — it only cares "did we get a token?" So LoginForm keeps its own email/password state, does the login fetch itself, and calls onLoggedIn(token) on success. This is colocation: keep state as close as possible to where it's used, and lift up only the thing that's actually shared [the token].

function LoginForm({ onLoggedIn }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    const formBody = new URLSearchParams();
    formBody.append("username", email);
    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,
    });

    if (response.ok) {
      const data = await response.json();
      onLoggedIn(data.access_token);   // report success UP to App
    } else {
      console.log("Login Failed:", response.status);
    }
  }
  // ...the form JSX
}
Enter fullscreen mode Exit fullscreen mode

App shed all the login state and shrank to a coordinator — it now owns only the token plus two tiny handlers:

function handleLoginSuccess(newToken) {
  localStorage.setItem("token", newToken);   // the side-effect stays with the owner of the token
  setToken(newToken);
}
function handleLogout() {
  localStorage.removeItem("token");
  setToken(null);
}
Enter fullscreen mode Exit fullscreen mode

<AddExpenseForm> came out the same way, except it needs two callbacks: onAdded [it succeeded — parent, please re-fetch the list] and onAuthError [the 401 guard]. And that surfaced a nice, normal wrinkle:

Prop-forwarding is fine and expected. ExpenseList receives onAuthError from App, and then passes it one level deeper to AddExpenseForm. A prop can be handed down through multiple layers. <AddExpenseForm onAdded={fetchExpenses} onAuthError={onAuthError} /> — notice onAdded={fetchExpenses} is me passing ExpenseList's own re-fetch function down so the child can trigger a refresh after a successful add. Same function, reused through a prop.

Two more real things fell out of the refactor:

Gotcha #11 — moving JSX into a child leaves leftovers in the parent. The instant I extracted <LoginForm>, my login screen showed the title twice. Cause: I'd put the <h1>Smart Expense Manager</h1> inside LoginForm, but App was still rendering its own old heading right above it. This is the exact same lesson as the stray-render leak from Part 1 — when you restructure, hunt the leftovers. The fix doubled as a design decision: the app title is shell UI, so it belongs in App, exactly once. LoginForm renders only the form.

Gotcha #12 — the tempting lint "fix" is an infinite-loop trap. ESLint started nagging: "React Hook useEffect has a missing dependency: 'fetchExpenses'." The obvious move is to obey it and write }, [fetchExpenses]). Don't. fetchExpenses is a plain function, so React creates a brand-new copy of it every render. Put it in the dependency array and the effect thinks its dependency changed on every render → it re-fetches → setState re-renders → new function copy → fetches again → infinite loop. My empty [] is actually the behavior I want [run once on mount]. So I made a real decision instead of cargo-culting: I left the warning alone. It's a warning, not an error, and the code is correct. I didn't even add an eslint-disable comment — that would just be decorating a non-problem. [The genuinely-correct fix is wrapping the function inuseCallbackso its identity is stable — but that cascades into memoizing the callbacks I pass down too, which is more machinery than a beginner project needs right now. useCallback = "know this exists."]

The whole app behaves identically after all this — same login, same CRUD, same 401 bounce. But App and ExpenseList are now thin coordinators, and each form is a small thing that owns its one job. That's the entire point of the refactor: no new features, just a codebase I can actually keep building on.

Debt #2: it looked like a 1998 government form [Tailwind]

Time to make it not-ugly. I chose Tailwind CSS [utility-first styling] and a modern SaaS-dashboard look — soft-gray canvas, white cards with subtle shadows, a top header bar. Partly because it's everywhere in industry, partly because that card-and-shell layout is a direct warm-up for the Phase 5 dashboard.

First lesson before I typed a single class:

Gotcha #13 — Tailwind v4 threw out the setup every tutorial still teaches. Search "install Tailwind" and you'll get v3 instructions: a tailwind.config.js, a content: [...] array, @tailwind base/components/utilities directives, PostCSS wiring. In v4, all of that is gone. It's now a Vite plugin plus a single line of CSS. I actually verified the current steps against today's docs instead of trusting my memory or an old blog — which is a habit I want to keep for fast-moving tools, because copying stale setup is a guaranteed hour lost.

The entire v4 setup, for a Vite + React app:

npm install tailwindcss @tailwindcss/vite
Enter fullscreen mode Exit fullscreen mode
// vite.config.js — ADD the plugin, keep react()
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})
Enter fullscreen mode Exit fullscreen mode
/* src/index.css — this ONE line replaces all the old @tailwind directives */
@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

A restart of the dev server later [config changes don't hot-reload — one to remember], I dropped className="text-3xl font-bold text-blue-600" on my title, watched it go big and blue, and knew the pipe was live. Note it's className, not class — the same JSX rule that bit me back in Part 1, now paying dividends because every Tailwind style rides on className.

The mental model that made Tailwind click: you don't write CSS rules in a separate file. You compose a look out of tiny, single-purpose utility classes right on the elementp-8 is padding, rounded-xl is corners, shadow-md is a shadow, bg-white is the background. Honest reaction: the className strings get long and repetitive, and my two login inputs share an identical one. That felt wrong at first, but it's genuinely idiomatic Tailwind. [Later I can collapse a repeated set with@applyin CSS, or extract a tiny<Input>component. Know this exists; not now.]

Here's the login screen — a centered white card floating on a gray page:

// App's logged-out branch
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
  <div className="w-full max-w-sm bg-white rounded-xl shadow-md p-8 flex flex-col gap-6">
    <h1 className="text-2xl font-bold text-slate-800 text-center">Smart Expense Manager</h1>
    <LoginForm onLoggedIn={handleLoginSuccess} />
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The handful of utilities I'll actually reuse forever:

  • flex items-center justify-centerthe centering combo. items-center centers vertically, justify-center horizontally. Wrap it in min-h-screen so there's a full viewport to center within.
  • flex flex-col gap-* — stack children in a column with even spacing between them, no margins to hand-tune.
  • focus:ring-2 / hover:bg-blue-700 — the focus: and hover: prefixes apply a style only in that state. It's Tailwind's answer to CSS pseudo-classes, and it's genuinely elegant — my inputs get a blue focus ring, my button darkens on hover, all inline.

Then the logged-in shell — a real SaaS layout with a header bar and a centered content column:

<div className="min-h-screen bg-slate-100">
  <header className="bg-white shadow-sm">
    <div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
      <h1 className="text-xl font-bold text-slate-800">Smart Expense Manager</h1>
      <button onClick={handleLogout} className="text-sm text-slate-600 border border-slate-300 rounded-md px-3 py-1.5 hover:bg-slate-50 transition-colors">
        Logout
      </button>
    </div>
  </header>
  <main className="max-w-3xl mx-auto px-6 py-8">
    <ExpenseList onAuthError={handleLogout} />
  </main>
</div>
Enter fullscreen mode Exit fullscreen mode

Two things worth banking here:

  • <header> and <main> instead of <div>s. These are semantic HTML — they render identically but tell browsers and screen readers what each region is. A free accessibility and clarity win; a small habit that separates "works" from "professional."
  • max-w-3xl mx-auto is the centered-column pattern. max-w-3xl caps the width so content isn't a painfully wide ribbon on a big monitor; mx-auto sets left/right margins to auto, which centers the block. I used the same max-w-3xl mx-auto px-6 on both the header's inner div and <main>, which is what keeps the logo and the content aligned down the page. justify-between in the header shoves the title hard-left and Logout hard-right.

And one small stumble that taught the right instinct:

Gotcha #14 — reach for gap on the parent, not a margin on each child. My title and the email box were jammed together with no space. My first instinct was to slap a margin-bottom on the heading. The cleaner fix — and the more Tailwind-idiomatic one — was to make the card a flex flex-col gap-6 container, so the gap spaces every child uniformly [title → form] with one declaration on the parent. Set the rhythm once, up top, instead of hand-tuning margins on each element and fighting margin quirks.

Honest status: this is the pass I've started, not finished. The login card and the whole app shell are dressed and look legitimately decent. What's not done yet: the expense-list itself is still raw inside that nice shell — the list needs to become a card, the add-expense form needs the same input/button styling as login, and each row needs tidy edit/delete buttons and clean loading/empty/error states. That's the frontier, and it flows directly into Phase 5.

The shape of every React conversation [the model that tied it together]

If Part 1's big mental model was "the Save button doesn't save — it asks the backend to," Part 2's is smaller but just as clarifying:

Data flows down, events flow up, and only a state change repaints the screen.

  1. A parent passes data or functions down to a child as props.
  2. When something happens in the child [login succeeded, a 401 came back, an expense was added], the child calls a function the parent gave it — that's the event flowing back up.
  3. That function, running in the parent, changes state [setToken(...)]. And state changing is the only thing that makes React re-render and swap the screen.

Every single thing I built this session is that loop. LoginFormonLoggedIn(token)setToken → the UI flips to logged-in. ExpenseListonAuthError()setToken(null) → the UI flips back to login. AddExpenseFormonAdded() → re-fetch → the list re-renders. Once I saw the one shape, the whole component tree stopped being mysterious.

Stuff I want to remember [the honest takeaways]

  • A JWT expiring is the security feature working, not a bug — but not handling the resulting 401 is the bug. Detect it and force a clean re-login.
  • Reproduce a time-based bug on purpose — corrupt the token in DevTools instead of waiting 30 minutes. An invalid token gives the same 401 as an expired one.
  • Check the specific status [401] before the generic !response.ok branch, or the general case masks the special one. Specific before general.
  • Props are how a parent passes data — and functions — down; a child reports events up by calling a function the parent gave it. Data down, events up.
  • { prop } in a component signature is object destructuring [by name]; useState's [value, setter] is array destructuring [by position]. Same idea, different brackets — and the prop name must match exactly.
  • Colocate state: each form owns its own state and its own API call; the parent stays thin and owns only what's shared [the token]. Lift up only the shared thing.
  • Prop-forwarding is normal — a prop can be handed down through several layers [AppExpenseListAddExpenseForm].
  • When you restructure, hunt the leftovers — moving JSX into a child left a duplicated <h1> behind in the parent. [Same lesson as Part 1's stray render. It keeps coming back.]
  • Don't cargo-cult a lint "fix." Obeying the exhaustive-deps warning naively [[fetchExpenses]] creates an infinite fetch loop, because the function is recreated every render. The empty [] was correct. A warning is not an error. [Real fix = useCallback; deferred.]
  • Tailwind v4 is nothing like the v3 tutorials — no config file, no content array, no @tailwind directives. Just the @tailwindcss/vite plugin + @import "tailwindcss";. Verify current docs for fast-moving tools.
  • Tailwind is utility-first: compose a look from tiny classes on className [still not class]. Long, repeated class strings are normal.
  • flex items-center justify-center centers; flex flex-col gap-* spaces stacked children [set spacing on the parent, not margins on each child]; focus:/hover: prefixes are Tailwind's pseudo-classes; max-w-* mx-auto is the centered column.
  • Use semantic <header>/<main> instead of <div> soup — free clarity and accessibility.
  • The one React loop: data down (props) → event up (callback) → setState → re-render. Everything I built is that shape.
  • Commit at every green checkpoint, subject + why. I committed after the 401 fix, after each extraction, and after the Tailwind install — small and often.

Next up: Phase 5, for real this time. The face now has a pulse [the session no longer dead-ends], its internals are organized [small components that each own their job], and it's wearing its first real clothes [a login card and a dashboard shell]. What's left is to finish dressing the expense list, and then give the app some actual expression: a dashboard with spending insights — totals, spend-by-category, month-over-month — turning "it works and it's tidy" into "I'd genuinely use this." The foundation's finally solid enough to build something interesting on top of. See you there.

Top comments (0)