DevTinder — Build Log
Building to ship — a developer-matching app: routing and layout, auth and protected feed, and a clean separation between UI and data.
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.
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 />.#
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.
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).
CRA / Parcel
Earlier scaffolds. CRA (Webpack) is heavier and slower to start; Parcel needs more hand-config. Vite is the current default choice.
vitecommand not found? A freshly cloned/scaffolded project has nonode_modulesyet — runnpm installbeforenpm run dev.- Find the start command in
package.json: any npm/React project lists its scripts there. For Vite it'snpm run dev, notnpm start. - Clean the boilerplate: strip the starter
App.jsxdown to a hello-world, delete the demo CSS and logo assets, then start building.main.jsxis the entry point that mounts<App />. - Git from the start:
git initearly; Vite ships a.gitignorethat already excludesnode_modules.
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
contentglob: 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.
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.
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"— notmodule.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. rafceshortcut: typingrafcein a.jsxfile scaffolds a full arrow-function component with export. (Only works in.jsx/.tsxfiles.)
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.
createBrowserRouter
A config-object API (used in the Netflix GPT build). Both are standard and widely used — same concepts, different syntax.
basenamesets the root:basename="/"means routes are relative to the site root (no prefix). Set it to/appand every route becomes/app/....- Import everything: "Route is not defined" usually means you forgot to import
Route/Routesfromreact-router-dom. - Treat routes as permanent: changing a path later hurts SEO and breaks links Google has indexed — plan the route map up front.
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.
Renders NavBar, then Outlet, then Footer — the fixed shell.
The slot where the matched child route renders.
/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.
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.
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/300so the footer and navbar share the same tone.
Gotchas & nice-to-knows#
vite: command not found
You skipped npm install after scaffolding/cloning. No node_modules means no Vite binary.
npm start does nothing
Vite's script is dev. Run npm run dev — check package.json for the real script names.
Tailwind classes ignored
The content glob doesn't match your files, so Tailwind purges them. Point it at ./src/**/*.
Child route renders nothing
The parent (Body) has no <Outlet />. Children need an outlet to mount into.
ES modules vs CommonJS
Frontend: import/export. Node backend: require/module.exports. Don't mix them up across the stack.
DaisyUI themes
Switch the whole look via a theme name — no manual recoloring of every component.
Interview Q&A#
Q.What is Vite and why use it over CRA?
Q.What's the difference between Tailwind and DaisyUI?
Q.How do you set up routing with React Router's component API?
<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?
Q.What does the basename prop on BrowserRouter do?
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?
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.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.#
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.
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 firesonChange→setEmailId(e.target.value). The state variable always mirrors what's on screen. - Initialize with a value: starting state at
""(notundefined) 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.
uncontrolled → controlled warning
Initializing state as undefined then setting it later flips the input's mode. Always start with useState("").
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,awaitthe request, and wrap it intry/catchso 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).
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.
Cross-origin request → CORS error. (Postman doesn't enforce CORS, so the API itself works.)
Add the cors middleware with the frontend origin whitelisted + credentials enabled.
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: trueand axioswithCredentials: 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.
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.
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/toolkitgivesconfigureStore/createSlice;react-reduxgivesProvider,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.
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;
reducervsreducers: the store config takes onereducer(singular); a slice defines manyreducers(plural). A classic mix-up — watch the s.- What a reducer returns becomes the new state:
addUserreturnsaction.payload(the user);removeUserreturnsnull. Whatever you return replaces the slice's value. - Export both: the named
actions(to dispatch) and the defaultreducer(to plug into the store).
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:
useDispatchgives thedispatchfunction (writes);useSelectorsubscribes to a slice (reads). Both fromreact-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).
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
useNavigateinside the handler. Call it at the component's top level, then use the returnednavigatefunction 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.
Cleanup: constants & a components folder#
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.
/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.
Gotchas & nice-to-knows#
CORS error in the browser
Cross-origin (different port counts). Add the cors middleware with origin + credentials on the backend.
Cookie never gets set
You set backend credentials: true but forgot axios withCredentials: true (or vice-versa). Both are required.
Invalid hook call
useNavigate/useSelector called inside a handler. Hooks only run at a component's top level.
reducer vs reducers
Store config uses reducer (one); a slice uses reducers (many). Easy typo, real bug.
Postman ignores CORS
The API working in Postman but failing in the browser is the classic CORS tell.
Redux DevTools blank?
Often a stale browser — update/relaunch Chrome and the store shows up.
Interview Q&A#
Q.What's a controlled component?
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?
Q.Why does the API work in Postman but fail in the browser?
Q.How do you send and receive cookies across origins?
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?
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?
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?
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?
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.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.#
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/viewtrades 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
userDatais 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.
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.
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.
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.
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 tonull; otherwise the navbar still shows the logged-in user until a refresh re-checks the (now-gone) cookie. - Then navigate: push the user to
/loginso they land somewhere valid.
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 (noterr.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.
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: feedReduceralongsideuser. - Pass data via props:
<UserCard user={...} />receives the profile and readsfirstName,photoUrl,age,gender,aboutfrom it. - Guard before indexing:
feed[0]throws when feed isnullon first render. Render the card only whenfeedexists; inside the card, guard optional fields ({age && gender && ...}).
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
showToasttrue, render a DaisyUItoast, and asetTimeoutflips it back to false after a few seconds — nicer than a blockingalert(). - 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.
Gotchas & nice-to-knows#
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.
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.
Avatar lingers after logout
You hit the logout API but forgot dispatch(removeUser()). Clear the store too.
err.message is generic
The real server error is at err.response.data. err.message just says "request failed with status 400."
Cannot read [0] of null
Rendering the feed card before data arrives. Guard with {feed && ...}.
Toast > alert
A DaisyUI toast with a timeout is non-blocking and looks like a real product; alert() halts everything.
Interview Q&A#
Q.How do you keep a user logged in across page refreshes?
/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?
/login. Since Body wraps every route, any URL accessed while logged out lands on login.Q.Why distinguish a 401 from other errors?
Q.What's the difference between an anchor tag and React Router's Link?
<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?
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?
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?
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?
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.#
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'sdatawraps 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.
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:
!connectionshandles "still loading / null" (return nothing);length === 0handles "loaded but none" (show a friendly message). They're different and both matter. - Always pass a
key: when mapping, setkey={_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.
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 fromrequest.fromUserId, notrequestdirectly — otherwise everything rendersundefined. - 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
requestSlicewithaddRequests, fetched from/user/requests/received, dispatched and read withuseSelector— identical to connections.
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 misplacewithCredentials. - 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.
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
showButtonsstate would hide buttons for every card at once. Removing the specific item by id is the correct per-row update.
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.
Gotchas & nice-to-knows#
404 on the API
Wrong path — it's /user/connections, not /connections. Check the exact route the backend exposes.
undefined, undefined
Reading the wrong level. The request's user is under request.fromUserId, not request.
Missing key warning
Every mapped element needs key={_id}. Use a stable unique id, not the index.
onClick fires on render
onClick={reviewRequest(...)} calls it immediately. Wrap it: onClick={() => reviewRequest(...)}.
Card doesn't disappear
You hit the API but didn't update the store. Dispatch removeRequest(_id) after the call.
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.
Interview Q&A#
Q.What's the repeatable recipe for adding a data-driven page?
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?
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?
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?
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?
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?
Q.You see "undefined, undefined" rendered — what's wrong?
fromUserId, so you must destructure from request.fromUserId. Inspect the actual response shape to find where the data lives.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.#
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_idand hand it to the handler (and toremoveUserFromFeed). - Guard the empty feed: after the last card is acted on,
feedis an empty array — checkfeed.length === 0and show "No new users found" so accessingfeed[0]doesn't crash.
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)}, notonClick={setIsLoginForm(...)}— the latter runs on render and triggers "too many re-renders."
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.
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
awaiton the axios call makesresa pending promise, sores.datais undefined; (2) the data is nested atres.data.data. Both showed as "user not added to the store."
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.
New user → auto-logged-in → edit profile.
From the feed, mark Interested — request goes out, card removed.
Other user (second session) sees the request, accepts it.
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.
Final polish#
Password input type
Set the password field to type="password" so it's masked — easy to forget while building.
Default empty controlled values
Seed edit fields like age with user.age || "" so a missing value doesn't flip the input uncontrolled and warn.
Title & meta
Change <title> in index.html from "Vite + React" to "DevTinder"; add meta description/keywords for SEO.
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.
Gotchas & nice-to-knows#
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.
res.data is undefined
Missing await on the axios call — res is still a pending promise. Await it.
too many re-renders
You called the setter directly in onClick. Wrap it: onClick={() => setIsLoginForm(v => !v)}.
Empty feed crash
Acting on the last card empties the array. Guard feed.length === 0 before reading feed[0].
age field warns
A null user.age makes the input uncontrolled. Default it to "".
Two browser profiles
Separate sessions = two logged-in users, perfect for testing request flows end-to-end.
Interview Q&A#
Q.How do you reuse one form for both login and signup?
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?
Q.You got res.data undefined after an API call — why?
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?
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.