Study Index
React · In Practice · DevTinder

React, building to ship

A fresh build log for the DevTinder frontend — a Tinder-style app for developers, on top of an existing backend. Vite, DaisyUI, and a clean routing layout, captured as decisions and tradeoffs, not live-coding fumbles.

Build 01

Project Setup & Routing Layout

Scaffold with Vite, style with DaisyUI, and lay out the app as one Body shell that swaps pages through an Outlet.

Set up the frontend the modern way: Vite to scaffold, DaisyUI on Tailwind for instant UI, and a routing layout where a single Body holds the navbar + footer and swaps the middle page through an <Outlet />.

1.1

What we're building

DevTinder is a "Tinder for developers" — a feed of developer profiles you can connect with. This artifact is the frontend; the backend already exists (signup, login, logout, profile, connection-request, and feed APIs), so the work here is the React app that consumes it.

  • Frontend on top of a real API: unlike a toy app, this one talks to an existing backend. Auth, feed, and profile screens map to endpoints that are already built.
  • Built incrementally: scaffold first, then layout/routing, then features (auth, feed, connections) in later builds. This build is the foundation.
1.2

Scaffolding with Vite

Vite is a fast, modern build tool — it scaffolds a project, runs a dev server, and bundles for production. One command creates a React project; npm install pulls dependencies, then npm run dev starts it.

// scaffold a React project with Vite
npm create vite@latest devtinder-web -- --template react

cd devtinder-web
npm install        // fetch dependencies (no node_modules yet)
npm run dev        // dev server, default http://localhost:5173
Vite (this project)

Fast dev server and build, lightweight, trending. Scripts live in package.json (dev, build, preview, lint).

vs
CRA / Parcel

Earlier scaffolds. CRA (Webpack) is heavier and slower to start; Parcel needs more hand-config. Vite is the current default choice.

  • vite command not found? A freshly cloned/scaffolded project has no node_modules yet — run npm install before npm run dev.
  • Find the start command in package.json: any npm/React project lists its scripts there. For Vite it's npm run dev, not npm start.
  • Clean the boilerplate: strip the starter App.jsx down to a hello-world, delete the demo CSS and logo assets, then start building. main.jsx is the entry point that mounts <App />.
  • Git from the start: git init early; Vite ships a .gitignore that already excludes node_modules.
1.3

Tailwind + DaisyUI for fast UI

Tailwind is a utility-first CSS toolkit (style via class names). DaisyUI is a component library built on Tailwind — it gives ready-made navbars, buttons, footers, and themes, so you assemble UI instead of writing CSS from scratch.

// install Tailwind, then DaisyUI as a Tailwind plugin
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install -D daisyui@latest

// tailwind.config.js
content: ["./src/**/*.{js,ts,jsx,tsx}"],   // files Tailwind scans
plugins: [require("daisyui")],            // register DaisyUI
// index.css - pull in Tailwind's layers
@tailwind base;
@tailwind components;
@tailwind utilities;
  • Set the content glob: Tailwind only keeps classes it finds in those files. Wrong paths → styles silently don't apply.
  • DaisyUI is a plugin, not a CDN: install it and register it in plugins. Don't use the CDN in production. Once added, the app picks up a DaisyUI theme automatically (e.g. a dark default).
  • Themes & semantic colors: DaisyUI ships themes (light, dark, corporate, synthwave…) and semantic color classes like bg-base-100/200/300 — pick from its color guide instead of agonizing over hex values.
Analogy · recall

Tailwind is a box of LEGO bricks (tiny utility classes); DaisyUI is a set of pre-built LEGO kits — a navbar, a card, a footer already assembled from those bricks. You can still snap individual bricks on to customize, but you start from a finished piece instead of a pile.

1.4

The NavBar as its own component

Grab a navbar from DaisyUI's components, then move it out of App.jsx into its own file. Keeping each piece of UI in a separate component file is the habit that keeps the app readable as it grows.

// NavBar.jsx (rafce snippet scaffolds this instantly)
const NavBar = () => {
  return (
    <div className="navbar bg-base-300">
      <div className="flex-1">
        <a className="btn btn-ghost text-xl">👨‍💻 DevTinder</a>
      </div>
      <div className="flex-none mx-5">{/* profile dropdown */}</div>
    </div>
  );
};
export default NavBar;
  • ES modules, not CommonJS: React/Vite use export default / import NavBar from "./NavBar" — not module.exports / require. The backend (Node) used CommonJS; the frontend uses ES modules.
  • It's JSX, not HTML: paste component markup as JSX (className, self-closing tags). DaisyUI snippets are HTML — adjust attributes when copying in.
  • rafce shortcut: typing rafce in a .jsx file scaffolds a full arrow-function component with export. (Only works in .jsx/.tsx files.)
1.5

Routing with React Router (component style)

React Router maps URLs to components. This project uses the JSX component style: wrap the app in <BrowserRouter>, group paths in <Routes>, and declare each as a <Route> with a path and an element.

import { BrowserRouter, Routes, Route } from "react-router-dom";

const App = () => (
  <BrowserRouter basename="/">
    <Routes>
      <Route path="/" element={<Body />}>
        <Route path="/login" element={<Login />} />
        <Route path="/profile" element={<Profile />} />
      </Route>
    </Routes>
  </BrowserRouter>
);
Component style (this project)

<BrowserRouter><Routes><Route> declared in JSX. Children routes nest by nesting <Route> tags.

vs
createBrowserRouter

A config-object API (used in the Netflix GPT build). Both are standard and widely used — same concepts, different syntax.

  • basename sets the root: basename="/" means routes are relative to the site root (no prefix). Set it to /app and every route becomes /app/....
  • Import everything: "Route is not defined" usually means you forgot to import Route / Routes from react-router-dom.
  • Treat routes as permanent: changing a path later hurts SEO and breaks links Google has indexed — plan the route map up front.
1.6

The Body + Outlet layout

The key pattern: one Body component is the parent route. It renders the persistent chrome (navbar, footer) once, and marks where the changing page should appear with <Outlet />. Child routes render into that outlet.

Body (parent /)

Renders NavBar, then Outlet, then Footer — the fixed shell.

<Outlet />

The slot where the matched child route renders.

child routes

/login, /profile, feed… each fills the Outlet in turn.

// Body.jsx
import { Outlet } from "react-router-dom";

const Body = () => (
  <div>
    <NavBar />
    <Outlet />     // matched child route renders here
    <Footer />
  </div>
);
  • Why nothing showed at first: a parent route renders child routes only where you place an <Outlet />. No outlet → the child has nowhere to mount, so the page looks empty.
  • Chrome stays, content swaps: NavBar and Footer are written once in Body. As the URL changes, only the Outlet's contents change — the shell never re-renders or duplicates.
  • Self-closing vs paired tags: <Route ... /> and <Route>...</Route> are equivalent; use the paired form when nesting child routes inside.
Analogy · recall

Body is a picture frame on the wall: the frame (navbar above, footer below) stays mounted, and <Outlet /> is the opening where the router slides in a different photo (login, profile, feed) depending on the URL. You hang the frame once; only the picture changes.

1.7

A persistent footer

Because the footer lives in Body below the Outlet, it shows on every page automatically — same benefit as the navbar. Drop in a DaisyUI footer component and position it.

  • Write it once: placing Footer in Body (not in each page) keeps it consistent everywhere without repetition — the layout's whole payoff.
  • Pinning it: a sticky/fixed footer is just CSS — e.g. fixed bottom-0 w-full — and there are several valid approaches depending on the layout you want.
  • Match the theme: reuse DaisyUI's bg-base-200/300 so the footer and navbar share the same tone.
1.8

Gotchas & nice-to-knows

Gotcha
vite: command not found

You skipped npm install after scaffolding/cloning. No node_modules means no Vite binary.

Gotcha
npm start does nothing

Vite's script is dev. Run npm run dev — check package.json for the real script names.

Gotcha
Tailwind classes ignored

The content glob doesn't match your files, so Tailwind purges them. Point it at ./src/**/*.

Gotcha
Child route renders nothing

The parent (Body) has no <Outlet />. Children need an outlet to mount into.

Nice to know
ES modules vs CommonJS

Frontend: import/export. Node backend: require/module.exports. Don't mix them up across the stack.

Nice to know
DaisyUI themes

Switch the whole look via a theme name — no manual recoloring of every component.

1.9

Interview Q&A

Q.What is Vite and why use it over CRA?
Vite is a modern build tool that scaffolds a project, runs a fast dev server, and bundles for production. It starts and rebuilds much faster than Create React App (which uses Webpack) and is lighter to configure. It's the current default for new React apps.
Q.What's the difference between Tailwind and DaisyUI?
Tailwind is a utility-first CSS framework — you style with small class names. DaisyUI is a component library built on top of Tailwind that gives you ready-made components (navbar, button, footer) and themes. You install DaisyUI as a Tailwind plugin and assemble UI faster.
Q.How do you set up routing with React Router's component API?
Wrap the app in <BrowserRouter>, group routes in <Routes>, and declare each path with a <Route path element>. Nest <Route> tags to create child routes. It's the JSX alternative to the config-based createBrowserRouter.
Q.What is an Outlet and why do you need one?
<Outlet /> marks the spot in a parent route where the matching child route renders. The parent (e.g. Body) keeps shared UI like the navbar and footer mounted, and only the Outlet's contents change as the URL changes. Without it, child routes have nowhere to appear.
Q.Why put the navbar and footer in a Body layout component?
So they're written once and stay consistent on every page. Body renders NavBar, an Outlet, and Footer; each route's page swaps into the Outlet while the chrome persists. It avoids repeating the navbar/footer in every page component.
Q.What does the basename prop on BrowserRouter do?
It sets the base path all routes are relative to. basename="/" means routes start at the site root with no prefix; basename="/app" would prefix every route with /app. Useful when the app is served from a sub-path.
Q.What's the difference between ES modules and CommonJS?
ES modules use import/export and are standard in frontend/React (and Vite). CommonJS uses require/module.exports and is common in older Node code. This project's React frontend uses ES modules; its Node backend used CommonJS.
Vite scaffold (npm create vite) npm install before npm run dev scripts live in package.json Tailwind: set content glob DaisyUI = Tailwind plugin bg-base-100/200/300 DaisyUI themes NavBar = own component rafce snippet ES modules (import/export) BrowserRouter / Routes / Route component-style routing basename = root path Body = layout shell <Outlet /> swaps the page navbar + footer persist plan routes (SEO)
Build 02

Login & the Redux Store

A real login: controlled form → axios POST → clear the CORS hurdles → store the user in Redux so the whole app reacts.

Logging in is the first end-to-end feature: a controlled form sends credentials via axios, the browser's CORS rules must be satisfied on both ends, and the returned user goes into a Redux store so every subscribed component (like the navbar) updates instantly.

2.1

The login form (DaisyUI card)

Assemble the login screen from DaisyUI pieces — a centered card with two labelled inputs (email, password) and a button. Layout is just Tailwind utilities (flex justify-center, margins); the components come ready-made.

  • Compose, don't hand-build: grab card + input + button from DaisyUI and adjust. The value is speed — you assemble UI instead of writing form CSS.
  • This is a component, not a page route yet: Login lives in the Body layout's Outlet (from Build 01), so the navbar/footer stay around it.
2.2

Controlled inputs: binding state to the UI

A controlled input is one whose value lives in React state. Hold each field in useState, set the input's value from it, and update it on every keystroke via onChange. State and UI stay in sync.

const [emailId, setEmailId] = useState("");   // init empty, not undefined
const [password, setPassword] = useState("");

<input
  type="text"
  value={emailId}
  onChange={(e) => setEmailId(e.target.value)}
/>
  • Two-way binding: the input shows value={emailId}, and typing fires onChangesetEmailId(e.target.value). The state variable always mirrors what's on screen.
  • Initialize with a value: starting state at "" (not undefined) avoids React's warning about switching an input from uncontrolled to controlled — a very common first bug.
  • Testing shortcut: seeding the initial state with a real email/password saves re-typing on every reload while developing.
Gotcha
uncontrolled → controlled warning

Initializing state as undefined then setting it later flips the input's mode. Always start with useState("").

2.3

Calling the API with axios

On login, an async handler POSTs the credentials to the backend's login endpoint. This project uses axios — a small wrapper over the browser's request machinery. (Plain fetch works too; axios is just more ergonomic.)

import axios from "axios";

const handleLogin = async () => {
  try {
    const res = await axios.post(
      BASE_URL + "/login",
      { emailId, password },
      { withCredentials: true }     // send/receive the cookie (see 2.4)
    );
    // res.data = the logged-in user
  } catch (err) {
    console.error(err);
  }
};
  • Async + try/catch: network calls are promises — mark the handler async, await the request, and wrap it in try/catch so failures are handled, not thrown into the void.
  • axios vs fetch: no real difference in capability — both wrap the same underlying request API. Pick one; this project uses axios.
  • Read the response at res.data: axios puts the parsed body there. The backend returns the full user object (better than a bare "login successful" string).
2.4

The CORS hurdle (the big one)

CORS (Cross-Origin Resource Sharing) is a browser security rule: a page can't freely call an API on a different origin. Frontend on localhost:5173 calling backend on localhost:7777 counts as cross-origin — even same host, different port is a different origin.

1. Browser blocks

Cross-origin request → CORS error. (Postman doesn't enforce CORS, so the API itself works.)

2. Backend allows

Add the cors middleware with the frontend origin whitelisted + credentials enabled.

3. Frontend opts in

axios sends withCredentials: true so the auth cookie is sent/stored.

// BACKEND (Express) - app.js
const cors = require("cors");
app.use(cors({
  origin: "http://localhost:5173",   // whitelist the frontend
  credentials: true,                  // allow cookies over the request
}));

// FRONTEND - every axios call that needs auth
axios.post(url, data, { withCredentials: true });
  • Origin = protocol + host + port: a different port (5173 vs 7777) is a different origin, so it triggers CORS. So would http vs https.
  • It's a browser thing: the API call succeeds from Postman because Postman ignores CORS. Chrome enforces it for safety — that's why it only breaks in the app.
  • Both ends are required for cookies: backend credentials: true and axios withCredentials: true. Miss either and the auth cookie won't be stored — every later authenticated call then fails.
  • Verify in DevTools: Network tab should show a 200; Application → Cookies should show the token. No cookie = something in the CORS/credentials setup is off.
  • Same-origin in production: if frontend and backend are served from one domain, none of this is needed — the whitelisting is a local-dev necessity.
Analogy · recall

CORS is a building's security desk. Postman is a courier with a master key who walks straight in. Your browser is a visitor who must be on the guest list: the backend (the desk) has to add your origin to the list (origin) and allow you to carry your badge (credentials), and you have to actually present the badge (withCredentials). Miss any step and you're turned away at the door.

2.5

Setting up the Redux Toolkit store

The logged-in user is needed app-wide (navbar, feed, profile), so it goes in a central Redux store. Redux Toolkit (RTK) is the modern, low-boilerplate way: configureStore creates the store, <Provider> hands it to the app.

// utils/appStore.js
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";

const appStore = configureStore({
  reducer: { user: userReducer },   // note: reducer (singular)
});
export default appStore;
// App.jsx - provide the store to everything
import { Provider } from "react-redux";

<Provider store={appStore}>
  {/* BrowserRouter / Routes ... */}
</Provider>
  • Two packages, two jobs: @reduxjs/toolkit gives configureStore/createSlice; react-redux gives Provider, useDispatch, useSelector. Install both: npm i @reduxjs/toolkit react-redux.
  • Provider at the root: wrap the app once so any component can read or write the store.
  • Setup order: install → configureStore → Provider → create slice → add the slice's reducer to the store. Do it in sequence.
2.6

A user slice

A slice bundles a piece of state with the functions that change it. createSlice takes a name, initial state, and reducers; it auto-generates matching actions you can dispatch.

// utils/userSlice.js
import { createSlice } from "@reduxjs/toolkit";

const userSlice = createSlice({
  name: "user",
  initialState: null,
  reducers: {                       // note: reducers (plural)
    addUser: (state, action) => action.payload,
    removeUser: (state, action) => null,
  },
});

export const { addUser, removeUser } = userSlice.actions;
export default userSlice.reducer;
  • reducer vs reducers: the store config takes one reducer (singular); a slice defines many reducers (plural). A classic mix-up — watch the s.
  • What a reducer returns becomes the new state: addUser returns action.payload (the user); removeUser returns null. Whatever you return replaces the slice's value.
  • Export both: the named actions (to dispatch) and the default reducer (to plug into the store).
2.7

Dispatch on login, read in the navbar

After a successful login, dispatch(addUser(res.data)) writes the user into the store. Any component subscribed via useSelector — like the navbar — re-renders automatically with the new data.

// Login.jsx - write to the store
const dispatch = useDispatch();
// inside handleLogin, after the API call:
dispatch(addUser(res.data));
// NavBar.jsx - read from the store
const user = useSelector((store) => store.user);

{user && (
  <div className="flex items-center">
    <p className="px-4">Welcome, {user.firstName}</p>
    <img src={user.photoUrl} alt="user photo" />
  </div>
)}
  • Write with dispatch, read with selector: useDispatch gives the dispatch function (writes); useSelector subscribes to a slice (reads). Both from react-redux.
  • This is the payoff: the navbar never talks to the login component — it just watches the store. Login dispatches; the navbar reacts. One source of truth, many subscribers.
  • Guard the render: when logged out the user is null, so {user && ...} hides the avatar/greeting until someone logs in.
  • Redux DevTools: the browser extension shows dispatched actions and store state — if it says "no store found," update/restart the browser (a real snag in this build).
2.8

Redirecting after login

Once the user is stored, send them to the feed. useNavigate from React Router returns a function you call with the target path.

import { useNavigate } from "react-router-dom";

const navigate = useNavigate();   // at the TOP of the component
// after dispatch(addUser(...)):
navigate("/");                    // go to the feed route
  • Hooks at the top, always: "invalid hook call" here came from calling useNavigate inside the handler. Call it at the component's top level, then use the returned navigate function anywhere.
  • Needs a target to render: navigating to / shows nothing until that route has an element — so a Feed component is wired into the / route's Outlet.
2.9

Cleanup: constants & a components folder

Constants
BASE_URL in one place

Move the hard-coded API URL into utils/constants.js and import it (BASE_URL + "/login"). Change the host once, everywhere updates.

Structure
/components and /utils

Move components (Body, NavBar, Login, Feed, Footer) into /components; keep store, slice, and constants in /utils. Fix the imports the move breaks.

  • No hard-coded strings: URLs in a constants file are the same hygiene rule as before — one edit propagates, and it keeps secrets/config out of component code.
  • Moving files breaks imports: expect "failed to resolve import" after reorganizing, and fix the relative paths (./components/Login, ../utils/userSlice). The editor often offers to auto-update them.
2.10

Gotchas & nice-to-knows

Gotcha
CORS error in the browser

Cross-origin (different port counts). Add the cors middleware with origin + credentials on the backend.

Gotcha
Cookie never gets set

You set backend credentials: true but forgot axios withCredentials: true (or vice-versa). Both are required.

Gotcha
Invalid hook call

useNavigate/useSelector called inside a handler. Hooks only run at a component's top level.

Gotcha
reducer vs reducers

Store config uses reducer (one); a slice uses reducers (many). Easy typo, real bug.

Nice to know
Postman ignores CORS

The API working in Postman but failing in the browser is the classic CORS tell.

Nice to know
Redux DevTools blank?

Often a stale browser — update/relaunch Chrome and the store shows up.

2.11

Interview Q&A

Q.What's a controlled component?
An input whose value is held in React state. You set the input's value from state and update it in onChange, so state and UI stay in sync. Initialize state with an empty string to avoid the "uncontrolled to controlled" warning.
Q.What is CORS and why does it block your API call?
CORS is a browser security policy that restricts requests to a different origin (protocol + host + port). A frontend on port 5173 calling a backend on 7777 is cross-origin, so the browser blocks it. The backend must explicitly allow the frontend's origin.
Q.Why does the API work in Postman but fail in the browser?
Because CORS is enforced by the browser, not the server. Postman doesn't apply CORS rules, so the request goes through; Chrome does, so it blocks the cross-origin call until the server whitelists the origin.
Q.How do you send and receive cookies across origins?
Two things together: the backend's CORS config needs credentials: true and a specific origin, and the frontend's axios call needs withCredentials: true. Miss either and the auth cookie won't be set, breaking later authenticated requests.
Q.How do you set up a Redux Toolkit store and use it?
Create it with configureStore (from RTK), wrap the app in <Provider store={appStore}> (from react-redux), and define state with createSlice. Write with dispatch(action) via useDispatch; read with useSelector.
Q.What's the difference between reducer and reducers?
The store's configureStore takes a single reducer object (one big reducer). A slice defines reducers (plural) — the individual functions that change that slice. Different keys, commonly confused.
Q.How does the navbar update the moment a user logs in?
The navbar subscribes to the store with useSelector((s) => s.user). When the login component dispatches addUser, the store changes and every subscriber re-renders — so the navbar shows the avatar/greeting without the two components ever talking directly.
Q.What causes an "invalid hook call" and how do you fix it?
Calling a hook somewhere other than the top level of a component — e.g. useNavigate() inside a click handler. Move the hook call to the component's top level and use its returned value (the navigate function) inside the handler.
DaisyUI card login form controlled input (value + onChange) useState("") to avoid warning axios.post + async/try-catch res.data = response body CORS = browser security origin = protocol+host+port backend cors: origin + credentials axios withCredentials: true cookie holds the token configureStore + Provider RTK + react-redux (2 packages) createSlice (addUser/removeUser) reducer vs reducers dispatch to write useSelector to read navbar reacts to store useNavigate at top level BASE_URL constant /components + /utils
Build 03

Auth Persistence, Feed & Profile

Stay logged in across refreshes, guard every route, log out cleanly, then render the feed and an editable profile.

The cookie is the source of truth, not the store: on load, fetch the user once and rehydrate Redux, redirect to login on a 401, and use Link for in-app navigation so the app never reloads. Then feed and profile are just more slice + API + component.

3.1

Staying logged in across a refresh

Refreshing the page wipes the Redux store (it's in-memory) — but the auth cookie survives in the browser. So on app load, ask the backend "who am I?" with the cookie, and if it answers, put the user back in the store. The user never sees a logout.

// Body.jsx - runs once when the app mounts
const userData = useSelector((store) => store.user);

const fetchUser = async () => {
  if (userData) return;                 // already have it; skip the call
  try {
    const res = await axios.get(
      BASE_URL + "/profile/view",
      { withCredentials: true }       // cookie identifies the user
    );
    dispatch(addUser(res.data));         // rehydrate the store
  } catch (err) {
    if (err.status === 401) navigate("/login");
  }
};

useEffect(() => { fetchUser(); }, []);   // once, on mount
  • The cookie outlives the store: Redux state resets on every refresh, but the HTTP-only cookie persists until it expires. /profile/view trades that cookie for the user object.
  • Put it in Body: Body is the always-mounted layout, so the check runs no matter which route you land on.
  • Skip redundant fetches: if userData is already in the store (you navigated in-app), return early — don't re-hit the API on every page.
  • StrictMode double-call: in dev you'll see the request fire twice; that's StrictMode, and it only happens locally.
3.2

Protecting routes & handling 401

If there's no valid cookie, /profile/view fails — and that failure is the signal to bounce the user to login. The backend should return a clear 401 Unauthorized (not a generic 400) so the frontend can tell "not logged in" apart from "something broke."

// BACKEND auth middleware - be explicit
return res.status(401).send("Please login");

// FRONTEND - branch on the status
catch (err) {
  if (err.status === 401) navigate("/login");   // not authed
  else console.error(err);                     // real error
}
  • Two kinds of failure: "you're not logged in" (401 → redirect to login) versus "the code/network broke" (everything else → error page or log). Distinguish them with the status code.
  • The guard is the redirect: because the check lives in Body and runs on load, typing any URL (/profile, /) while logged out lands you on login. No separate "ProtectedRoute" wrapper needed for this app.
  • Use clear status codes server-side: returning a proper 401 instead of throwing a vague error is what makes the frontend logic clean.
3.3

Link vs anchor: SPA navigation

An <a href> does a full page reload — which re-runs the load logic and re-fetches. React Router's <Link> does client-side navigation instead: it swaps the route without reloading, so the store survives and no needless API call fires.

import { Link } from "react-router-dom";

// instead of ...
<Link to="/profile">Profile</Link>
<Link to="/">DevTinder</Link>
  • Anchor = reload, Link = swap: clicking an <a> reloads the whole app (store reset, profile re-fetched). <Link> changes only the route — instant, and the cached store data is reused.
  • This is why "extra" API calls appeared: navigating via the address bar (a reload) re-fetches; navigating via <Link> doesn't. Use <Link> for all in-app navigation.
Analogy · recall

An <a href> is leaving the building and walking back in through the front door — you re-clear security every time. <Link> is taking the elevator between floors: you're already inside, so you just move to the new floor instantly. Same destination, no re-entry.

3.4

Logout: API + clear store + redirect

Logout is three coordinated steps: hit the logout endpoint (the server expires the cookie), clear the user from Redux, and navigate to login. Skip the store-clear and the avatar lingers until the next refresh.

const handleLogout = async () => {
  try {
    await axios.post(BASE_URL + "/logout", {}, { withCredentials: true });
    dispatch(removeUser());   // clear Redux (sets user to null)
    navigate("/login");     // send them out
  } catch (err) {
    // redirect to an error page
  }
};
  • Server clears the cookie: the logout endpoint sets the token to null with an immediate expiry, so the browser drops it.
  • Client must clear the store too: dispatch(removeUser()) resets the slice to null; otherwise the navbar still shows the logged-in user until a refresh re-checks the (now-gone) cookie.
  • Then navigate: push the user to /login so they land somewhere valid.
3.5

Showing login errors in the UI

A failed login should tell the user why ("Invalid credentials"), not crash silently. Keep an error state, set it from the API error in the catch, and render it in red above the button.

const [error, setError] = useState("");

// in handleLogin's catch:
setError(err?.response?.data || "Something went wrong");

// in the form, above the button:
<p className="text-red-500">{error}</p>
  • The real message is at err.response.data: axios nests the server's error body there (not err.message, which is axios's generic "request failed with status 400"). Use optional chaining and a fallback.
  • State-driven message: store the error in state so the UI re-renders to show it — the same pattern as any controlled piece of UI.
3.6

The feed: a new slice + UserCard

The feed is the same recipe as the user: a feedSlice, an API call on mount, dispatch into the store, and a component to render it. Each new data source gets its own slice plugged into the store.

// utils/feedSlice.js
const feedSlice = createSlice({
  name: "feed",
  initialState: null,
  reducers: {
    addFeed: (state, action) => action.payload,
    removeFeed: (state, action) => null,
  },
});
// Feed.jsx
const feed = useSelector((store) => store.feed);

const getFeed = async () => {
  if (feed) return;                          // cached; skip
  const res = await axios.get(BASE_URL + "/feed", { withCredentials: true });
  dispatch(addFeed(res.data));
};
useEffect(() => { getFeed(); }, []);

// render a card from the data (guard the empty state)
{feed && <UserCard user={feed[0]} />}
  • Slice per concern: feed data can't live in the user slice — add the store key feed: feedReducer alongside user.
  • Pass data via props: <UserCard user={...} /> receives the profile and reads firstName, photoUrl, age, gender, about from it.
  • Guard before indexing: feed[0] throws when feed is null on first render. Render the card only when feed exists; inside the card, guard optional fields ({age && gender && ...}).
3.7

Editable profile: PATCH + toast

The profile page is a form pre-filled from the store, with each field a controlled input. On save, PATCH the changes to the backend; on success, show a temporary toast confirmation.

// each field is controlled state, seeded from the user
const [firstName, setFirstName] = useState(user.firstName);
// ...lastName, age, gender, about

const saveProfile = async () => {
  try {
    const res = await axios.patch(
      BASE_URL + "/profile/edit",
      { firstName, lastName, age, gender, about },
      { withCredentials: true }
    );
    dispatch(addUser(res.data));   // keep store in sync
    setShowToast(true);
    setTimeout(() => setShowToast(false), 3000);  // auto-hide
  } catch (err) { /* handle */ }
};
  • PATCH for partial updates: editing some fields is a PATCH (vs POST to create). After saving, dispatch addUser(res.data) so the navbar/profile reflect the change immediately.
  • Toast via state + timeout: set showToast true, render a DaisyUI toast, and a setTimeout flips it back to false after a few seconds — nicer than a blocking alert().
  • Right element for the job: use a <textarea> for the long "about" field and a dropdown for gender — small UX wins over a row of plain inputs.
3.8

Gotchas & nice-to-knows

Gotcha
Logged out on refresh

The store is in-memory. Re-fetch the user from the cookie on app load (in Body) to persist the session.

Gotcha
Redundant API calls

Navigating via <a href> or the address bar reloads and re-fetches. Use <Link>, and skip the fetch if data's already in the store.

Gotcha
Avatar lingers after logout

You hit the logout API but forgot dispatch(removeUser()). Clear the store too.

Gotcha
err.message is generic

The real server error is at err.response.data. err.message just says "request failed with status 400."

Gotcha
Cannot read [0] of null

Rendering the feed card before data arrives. Guard with {feed && ...}.

Nice to know
Toast > alert

A DaisyUI toast with a timeout is non-blocking and looks like a real product; alert() halts everything.

3.9

Interview Q&A

Q.How do you keep a user logged in across page refreshes?
The Redux store resets on refresh, but the auth cookie persists. On app load, call a "who am I" endpoint (here /profile/view) with the cookie; if it returns the user, dispatch it back into the store. The session is restored without the user noticing.
Q.How do you protect routes in this app?
The Body layout checks auth on load by fetching the user. If the request returns 401 (no valid cookie), it redirects to /login. Since Body wraps every route, any URL accessed while logged out lands on login.
Q.Why distinguish a 401 from other errors?
A 401 means "not authenticated" — the right response is to redirect to login. Other errors (network, server bug) shouldn't bounce the user to login; they go to an error page or log. Branching on the status code keeps the two cases separate.
Q.What's the difference between an anchor tag and React Router's Link?
An <a href> triggers a full page reload — the app restarts, the store resets, data re-fetches. <Link> does client-side navigation: it swaps the route without reloading, so state is preserved and no redundant API calls fire. Use <Link> inside an SPA.
Q.What are the steps to log a user out?
Call the logout endpoint (the server expires the cookie), clear the user from Redux with dispatch(removeUser()), then navigate to login. Forgetting the store-clear leaves the UI showing the logged-in user until the next refresh.
Q.How do you read and display an API error message?
With axios, the server's error body is at err.response.data (use optional chaining and a fallback). Store it in an error state and render it, so the UI re-renders to show the message — e.g. "Invalid credentials" in red.
Q.How do you avoid re-fetching data you already have?
Read the slice with useSelector first; if the data is present, return early before the API call. So once the feed or user is in the store, navigating around (via <Link>) reuses it instead of hitting the network again.
Q.POST vs PATCH — which for editing a profile?
PATCH, because you're partially updating an existing resource (POST is for creating). After a successful PATCH, dispatch the updated user back into the store so every subscribed component reflects the change right away.
store resets on refresh cookie persists the session fetch user on load (Body) rehydrate Redux 401 → redirect to login distinguish 401 vs other errors skip fetch if data in store <Link> not <a href> Link = no reload, store kept logout: API + removeUser + navigate err.response.data for messages error in state → red text feedSlice (addFeed/removeFeed) UserCard via props guard {feed && ...} edit profile = controlled fields PATCH /profile/edit dispatch addUser to sync toast + setTimeout (not alert)
Build 04

Connections & Requests

Two more pages by the same recipe, then act on a request — accept/reject with one parameterized call, and update the UI from the store.

New pages are a repeatable recipe: slice → fetch on mount → dispatch → render from the store. The new idea is acting on data — one parameterized handler accepts or rejects a request, then a reducer filters it out of the store so the card vanishes without a refetch.

4.1

The connections page (recipe in action)

By now the pattern is muscle memory: add a route + nav <Link>, make a slice, fetch the data on mount, dispatch it into the store, and render with useSelector. Connections is the fourth time through — the speed comes from repetition, not magic.

// utils/connectionSlice.js - same shape as before
const connectionSlice = createSlice({
  name: "connection",
  initialState: null,
  reducers: {
    addConnections: (state, action) => action.payload,
    removeConnections: (state, action) => null,
  },
});
// Connections.jsx
const connections = useSelector((store) => store.connection);

const fetchConnections = async () => {
  const res = await axios.get(BASE_URL + "/user/connections", { withCredentials: true });
  dispatch(addConnections(res.data.data));   // note: data.data
};
useEffect(() => { fetchConnections(); }, []);
  • Mind the response shape: this API nests the array at res.data.data (axios's data wraps the server's { data: [...] }). Log the response once to confirm where the array actually lives.
  • Add the nav link too: a route is useless without a way in — add a <Link to="/connections"> in the navbar dropdown.
  • Slice per page is fine: even data you don't reuse elsewhere can live in the store for consistency — or use local state; both are valid here.
4.2

Rendering a list: map, guard, key

Turn the array into cards with .map. Guard the empty states first (no data vs. zero-length), and give each mapped element a unique key so React can track them.

if (!connections) return;                          // data not loaded yet
if (connections.length === 0)
  return <h1>No connections found</h1>;          // loaded but empty

return connections.map((connection) => {
  const { _id, firstName, lastName, photoUrl, age, gender, about } = connection;
  return (
    <div key={_id} className="flex ...">
      <img src={photoUrl} />
      <h2>{firstName + " " + lastName}</h2>
      {age && gender && <p>{age + ", " + gender}</p>}
      <p>{about}</p>
    </div>
  );
});
  • Two empty states, two checks: !connections handles "still loading / null" (return nothing); length === 0 handles "loaded but none" (show a friendly message). They're different and both matter.
  • Always pass a key: when mapping, set key={_id} on the top element. Missing keys trigger React's console warning and can cause subtle re-render bugs — use a stable unique id, not the array index.
  • Guard optional fields: {age && gender && ...} avoids rendering "undefined, undefined" when those fields are missing.
4.3

The requests page & a nested data shape

Requests is the same recipe with one twist: the user you want to show isn't at the top of each item — it's nested under fromUserId (the person who sent the request). Read the response carefully and reach for the right field.

// each request item looks like:
// { _id, fromUserId: { firstName, photoUrl, ... }, status }

requests.map((request) => {
  const { _id, firstName, photoUrl, age, gender, about } = request.fromUserId;
  // render the sender's card...
});
  • The sender is under fromUserId: a connection request references who sent it. Destructure from request.fromUserId, not request directly — otherwise everything renders undefined.
  • Inspect before coding: "undefined, undefined" on screen means you're reading the wrong level. Check the Network response (or log it) to find where the fields actually sit.
  • Same slice machinery: a requestSlice with addRequests, fetched from /user/requests/received, dispatched and read with useSelector — identical to connections.
4.4

Acting on data: one parameterized handler

Accept and reject are the same call with a different status — so write one handler that takes (status, id) and builds the URL dynamically. Two buttons, one function, no duplication.

const reviewRequest = async (status, _id) => {
  try {
    await axios.post(
      BASE_URL + "/request/review/" + status + "/" + _id,
      {},                              // no body; status & id are in the URL
      { withCredentials: true }
    );
    dispatch(removeRequest(_id));        // drop it from the store (see 4.5)
  } catch (err) { /* handle */ }
};

// two buttons, same function:
<button onClick={() => reviewRequest("rejected", _id)}>Reject</button>
<button onClick={() => reviewRequest("accepted", _id)}>Accept</button>
  • Parameterize instead of duplicating: the status ("accepted"/"rejected") and the request id are passed in, so one function serves both buttons. Wrap the call in an arrow (() => reviewRequest(...)) so it runs on click, not on render.
  • Empty body, data in the URL: this POST carries status and id as URL segments, so the second axios arg is {} and the third is the credentials config. Skipping the empty body would misplace withCredentials.
  • Pass the right id: use the request's _id (the request record), not the user's id — a common mix-up that makes the call fail.
4.5

Updating the UI: remove from the store

After accepting/rejecting, the card should disappear immediately — without re-fetching. A removeRequest reducer filters the handled item out of the array, and because the component reads the store, it re-renders with the card gone.

// requestSlice reducers
removeRequest: (state, action) =>
  state.filter((req) => req._id !== action.payload),
  • Filter to a new array: return state.filter(r => r._id !== id) — everything except the one just handled. The reducer returns the new list, the store updates, the UI follows.
  • No refetch needed: local store update is instant and avoids a round-trip. (A refresh would also work, since the backend already recorded the change — but updating the store is the snappy UX.)
  • Don't toggle a shared flag: a single showButtons state would hide buttons for every card at once. Removing the specific item by id is the correct per-row update.
Analogy · recall

The request list is an inbox. Accepting or rejecting is dealing with one email — you don't reload the whole inbox, you just remove that one message (filter it out) and the list redraws itself. The server already filed it; the UI just reflects the change locally.

4.6

Gotchas & nice-to-knows

Gotcha
404 on the API

Wrong path — it's /user/connections, not /connections. Check the exact route the backend exposes.

Gotcha
undefined, undefined

Reading the wrong level. The request's user is under request.fromUserId, not request.

Gotcha
Missing key warning

Every mapped element needs key={_id}. Use a stable unique id, not the index.

Gotcha
onClick fires on render

onClick={reviewRequest(...)} calls it immediately. Wrap it: onClick={() => reviewRequest(...)}.

Gotcha
Card doesn't disappear

You hit the API but didn't update the store. Dispatch removeRequest(_id) after the call.

Nice to know
response.data.data

axios wraps the body in .data; if the server also wraps its payload in data, you reach the array at res.data.data.

4.7

Interview Q&A

Q.What's the repeatable recipe for adding a data-driven page?
Add a route and a nav link, create a slice for the data, fetch it on mount in a useEffect, dispatch the result into the store, and render it with useSelector. Connections and requests both follow this exact pattern.
Q.Why does each item in a mapped list need a key?
React uses the key to identify which items changed, were added, or removed, so it can update the DOM efficiently. Use a stable unique id (like _id), not the array index, to avoid subtle re-render bugs — and React warns when it's missing.
Q.How do you handle two empty states in a list view?
Distinguish "not loaded yet" from "loaded but empty." Return nothing (or a spinner) when the data is still null, and show a friendly "none found" message when it's an empty array (length === 0).
Q.How would you handle accept and reject with one function?
Parameterize it: a single reviewRequest(status, id) handler builds the URL from the status and id, so both buttons call it with different arguments. It avoids duplicate code and keeps the logic in one place.
Q.After accepting a request, how do you update the UI without refetching?
Dispatch a reducer that filters the handled item out of the store array (state.filter(r => r._id !== id)). The component reads the store, so it re-renders with that card removed — instant, no extra network call.
Q.Why not use a single showButtons flag to hide the buttons?
Because one shared flag toggles every card at once. In a mapped list you need per-row behavior — the right approach is to remove the specific item by its id from the store, so only that card updates.
Q.You see "undefined, undefined" rendered — what's wrong?
You're reading fields from the wrong level of the response. For a request, the user's details are nested under fromUserId, so you must destructure from request.fromUserId. Inspect the actual response shape to find where the data lives.
recipe: slice → fetch → dispatch → render connectionSlice / requestSlice add route + nav Link res.data.data shape .map to cards key={_id} (not index) !data vs length === 0 guard optional fields request.fromUserId nesting reviewRequest(status, id) dynamic URL segments empty body {}, then credentials () => fn() on click removeRequest = filter by id update store, no refetch per-row, not a shared flag
Build 05

Send/Ignore, Signup & Testing

Act on the feed, reuse one form for login and signup, then drive the whole app end-to-end — the finale.

Closing the loop: send/ignore from the feed reuses the act → remove from store pattern, signup reuses the login form via one toggle flag, and the backend must set the cookie on signup so a new user is logged in instantly. Then test the full flow across real sessions.

5.1

Sending & ignoring from the feed

The feed's Interested/Ignore buttons are the same shape as accept/reject from Build 04: one parameterized handler POSTs the status, then a reducer filters that user out of the feed so the next card slides up.

const handleSendRequest = async (status, userId) => {
  try {
    await axios.post(
      BASE_URL + "/request/send/" + status + "/" + userId,
      {},
      { withCredentials: true }
    );
    dispatch(removeUserFromFeed(userId));    // drop the card
  } catch (err) { /* handle */ }
};

// two buttons, one handler, different status:
<button onClick={() => handleSendRequest("ignored", _id)}>Ignore</button>
<button onClick={() => handleSendRequest("interested", _id)}>Interested</button>
// feedSlice - remove the acted-on user by id
removeUserFromFeed: (state, action) =>
  state.filter((user) => user._id !== action.payload),
  • Same pattern, new endpoint: "interested"/"ignored" map to /request/send/:status/:userId. The card disappears because the slice filters it out — no refetch.
  • Pass the user's _id: the feed card already has the user object, so extract _id and hand it to the handler (and to removeUserFromFeed).
  • Guard the empty feed: after the last card is acted on, feed is an empty array — check feed.length === 0 and show "No new users found" so accessing feed[0] doesn't crash.
5.2

One form, two modes: login ↔ signup

Signup is login plus first/last name. Rather than a second component, reuse the login form and flip it with a boolean. isLoginForm drives the heading, the button, which fields show, and the toggle link — one state, many conditional renders.

const [isLoginForm, setIsLoginForm] = useState(true);

<h1>{isLoginForm ? "Login" : "Sign Up"}</h1>

{!isLoginForm && (
  <>
    <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
    <input value={lastName} onChange={(e) => setLastName(e.target.value)} />
  </>
)}

<button onClick={isLoginForm ? handleLogin : handleSignUp}>
  {isLoginForm ? "Login" : "Sign Up"}
</button>

<p onClick={() => setIsLoginForm((v) => !v)}>
  {isLoginForm ? "New user? Sign up here" : "Existing user? Login here"}
</p>
  • One flag, everything reacts: the heading, button text, button handler, extra fields, and the switch link all read isLoginForm. Toggling it re-renders the form in the other mode.
  • Conditional fields need a fragment: rendering two inputs under one {!isLoginForm && (...)} requires wrapping them in <>...</> — JSX allows only one returned parent.
  • Wrap the toggle in an arrow: onClick={() => setIsLoginForm(v => !v)}, not onClick={setIsLoginForm(...)} — the latter runs on render and triggers "too many re-renders."
Analogy · recall

The form is a reversible jacket. It's one garment, not two — flip it (isLoginForm) and the lining shows: extra pockets appear (first/last name), the label changes, the function it calls changes. You never stitch a second jacket; you just turn the same one inside out.

5.3

Signup must log the user in

The signup API call mirrors login but sends all four fields. The catch: signup only created the user — it didn't authenticate them. Fix it on the backend so signup also returns the user and sets the cookie, so the new user is logged in immediately.

// FRONTEND
const handleSignUp = async () => {
  try {
    const res = await axios.post(
      BASE_URL + "/signup",
      { firstName, lastName, emailId, password },
      { withCredentials: true }
    );
    dispatch(addUser(res.data.data));   // note: data.data, and AWAIT above
    navigate("/profile");             // new users edit their profile first
  } catch (err) { setError(err?.response?.data); }
};
// BACKEND signup - return the user AND set the cookie
const savedUser = await user.save();
const token = await savedUser.getJWT();
res.cookie("token", token, { expires: /* ... */ });
res.json({ message: "User added", data: savedUser });
  • Signup ≠ login (until you make it so): creating a user doesn't authenticate them. Generate the JWT and set the cookie on signup so the user lands logged in — otherwise they sign up and still aren't authenticated.
  • Send a new user to /profile: they've only entered name + credentials, so route them to edit their profile before the feed.
  • The two bugs to remember: (1) forgetting await on the axios call makes res a pending promise, so res.data is undefined; (2) the data is nested at res.data.data. Both showed as "user not added to the store."
5.4

End-to-end testing

With every feature built, drive the real app: sign up users, send requests, accept them in another session, and watch connections sync. Two browser profiles (separate sessions) let you act as two users at once.

Sign up

New user → auto-logged-in → edit profile.

Send

From the feed, mark Interested — request goes out, card removed.

Accept

Other user (second session) sees the request, accepts it.

Verify

Both appear in each other's Connections.

  • Two sessions, two users: a second browser profile holds an independent cookie, so you can log in as two people and watch a request flow from sender to receiver to mutual connection.
  • Console must be clean: treat any console error as a bug to fix. A 401 in the log meant an unauthorized access slipped through; chase it down rather than ignore it.
  • Test where bugs hide: a leftover bug (a user seeing themselves in the feed) traced to the store, not the API — the backend was already verified. When data looks wrong, check the slice and the response shape before blaming the server.
5.5

Final polish

Security
Password input type

Set the password field to type="password" so it's masked — easy to forget while building.

Robustness
Default empty controlled values

Seed edit fields like age with user.age || "" so a missing value doesn't flip the input uncontrolled and warn.

Branding
Title & meta

Change <title> in index.html from "Vite + React" to "DevTinder"; add meta description/keywords for SEO.

UX
Responsiveness

DaisyUI gets you most of the way; tidy the remaining breakpoints so it works on mobile.

That's the full DevTinder frontend — Vite scaffold, DaisyUI, routing layout, auth that persists, feed, profile, connections, requests, and signup, all on a real backend. Production-ready, end to end.

5.6

Gotchas & nice-to-knows

Gotcha
Signup doesn't log you in

The backend created the user but never set the cookie. Generate the JWT and res.cookie(...) on signup too.

Gotcha
res.data is undefined

Missing await on the axios call — res is still a pending promise. Await it.

Gotcha
too many re-renders

You called the setter directly in onClick. Wrap it: onClick={() => setIsLoginForm(v => !v)}.

Gotcha
Empty feed crash

Acting on the last card empties the array. Guard feed.length === 0 before reading feed[0].

Gotcha
age field warns

A null user.age makes the input uncontrolled. Default it to "".

Nice to know
Two browser profiles

Separate sessions = two logged-in users, perfect for testing request flows end-to-end.

5.7

Interview Q&A

Q.How do you reuse one form for both login and signup?
Keep a boolean like isLoginForm in state and let it drive everything — the heading, button label, which submit handler runs, the extra name fields, and the toggle link. Flipping the flag re-renders the same form in the other mode, avoiding a duplicate component.
Q.Why didn't a newly signed-up user end up logged in?
Because signup only created the user record — it didn't authenticate them. To auto-login, the backend must generate the JWT and set the auth cookie in the signup response (just like login does), and the frontend dispatches the returned user into the store.
Q.You got res.data undefined after an API call — why?
Most likely a missing await, so res is still a pending promise rather than the resolved response. Add await (and check the actual shape — the payload may be nested at res.data.data).
Q.What causes "too many re-renders" on a toggle?
Calling the state setter directly in JSX (e.g. onClick={setIsLoginForm(...)}) runs it during render, which schedules another render, looping. Wrap it in an arrow function so it only runs on the actual event.
Q.How do you send/ignore a user from the feed and update the UI?
One parameterized handler POSTs the status ("interested"/"ignored") with the user's id, then dispatches a reducer that filters that user out of the feed slice. The component reads the store, so the card disappears and the next one shows — no refetch.
Q.How would you test a two-user flow like sending and accepting a request?
Use two separate browser sessions (e.g. two Chrome profiles) so each has its own auth cookie. Log in as two users, send a request from one, accept it in the other, and verify both appear in each other's connections — a true end-to-end check.
Q.A user saw themselves in their own feed — how do you debug it?
Localize first: the backend feed API was already tested to exclude the logged-in user, so suspect the frontend store. Inspect the Redux state and the response shape — stale or mis-keyed store data is the likely cause, not the API.
Q.Why should the console stay error-free?
Console errors are real signals, not noise. A logged 401, for instance, means an unauthorized request slipped through. Leaving them masks bugs; fixing each one keeps the app correct and maintainable.
feed send/ignore = act + filter handleSendRequest(status, userId) removeUserFromFeed reducer guard feed.length === 0 one form, isLoginForm flag conditional fields + fragment () => setter (avoid re-render loop) signup must set the cookie auto-login new user await + res.data.data new user → /profile two sessions for e2e keep console error-free debug store before API type="password" default controlled values to "" set title + meta DaisyUI responsiveness