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.
<Outlet />.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.
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
Fast dev server and build, lightweight, trending. Scripts live in package.json (dev, build, preview, lint).
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.package.json: any npm/React project lists its scripts there. For Vite it's npm run dev, not npm start.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 init early; Vite ships a .gitignore that already excludes node_modules.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;
content glob: Tailwind only keeps classes it finds in those files. Wrong paths → styles silently don't apply.plugins. Don't use the CDN in production. Once added, the app picks up a DaisyUI theme automatically (e.g. a dark default).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.
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;
export default / import NavBar from "./NavBar" — not module.exports / require. The backend (Node) used CommonJS; the frontend uses ES modules.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.)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> );
<BrowserRouter><Routes><Route> declared in JSX. Children routes nest by nesting <Route> tags.
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/....Route / Routes from react-router-dom.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> );
<Outlet />. No outlet → the child has nowhere to mount, so the page looks empty.<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.
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.
fixed bottom-0 w-full — and there are several valid approaches depending on the layout you want.bg-base-200/300 so the footer and navbar share the same tone.You skipped npm install after scaffolding/cloning. No node_modules means no Vite binary.
Vite's script is dev. Run npm run dev — check package.json for the real script names.
The content glob doesn't match your files, so Tailwind purges them. Point it at ./src/**/*.
The parent (Body) has no <Outlet />. Children need an outlet to mount into.
Frontend: import/export. Node backend: require/module.exports. Don't mix them up across the stack.
Switch the whole look via a theme name — no manual recoloring of every component.
<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.<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.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.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.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.
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)} />
value={emailId}, and typing fires onChange → setEmailId(e.target.value). The state variable always mirrors what's on screen."" (not undefined) avoids React's warning about switching an input from uncontrolled to controlled — a very common first bug.Initializing state as undefined then setting it later flips the input's mode. Always start with useState("").
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, await the request, and wrap it in try/catch so failures are handled, not thrown into the void.res.data: axios puts the parsed body there. The backend returns the full user object (better than a bare "login successful" string).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 });
credentials: true and axios withCredentials: true. Miss either and the auth cookie won't be stored — every later authenticated call then fails.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.
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>
@reduxjs/toolkit gives configureStore/createSlice; react-redux gives Provider, useDispatch, useSelector. Install both: npm i @reduxjs/toolkit react-redux.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.addUser returns action.payload (the user); removeUser returns null. Whatever you return replaces the slice's value.actions (to dispatch) and the default reducer (to plug into the store).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> )}
useDispatch gives the dispatch function (writes); useSelector subscribes to a slice (reads). Both from react-redux.null, so {user && ...} hides the avatar/greeting until someone logs in.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
useNavigate inside the handler. Call it at the component's top level, then use the returned navigate function anywhere./ shows nothing until that route has an element — so a Feed component is wired into the / route's Outlet.Move the hard-coded API URL into utils/constants.js and import it (BASE_URL + "/login"). Change the host once, everywhere updates.
Move components (Body, NavBar, Login, Feed, Footer) into /components; keep store, slice, and constants in /utils. Fix the imports the move breaks.
./components/Login, ../utils/userSlice). The editor often offers to auto-update them.Cross-origin (different port counts). Add the cors middleware with origin + credentials on the backend.
You set backend credentials: true but forgot axios withCredentials: true (or vice-versa). Both are required.
useNavigate/useSelector called inside a handler. Hooks only run at a component's top level.
Store config uses reducer (one); a slice uses reducers (many). Easy typo, real bug.
The API working in Postman but failing in the browser is the classic CORS tell.
Often a stale browser — update/relaunch Chrome and the store shows up.
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.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.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.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.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.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.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
/profile/view trades that cookie for the user object.userData is already in the store (you navigated in-app), return early — don't re-hit the API on every page.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 }
/profile, /) while logged out lands you on login. No separate "ProtectedRoute" wrapper needed for this app.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>
<a> reloads the whole app (store reset, profile re-fetched). <Link> changes only the route — instant, and the cached store data is reused.<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 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 } };
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./login so they land somewhere valid.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>
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.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]} />}
feed: feedReducer alongside user.<UserCard user={...} /> receives the profile and reads firstName, photoUrl, age, gender, about from it.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 && ...}).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 */ } };
addUser(res.data) so the navbar/profile reflect the change immediately.showToast true, render a DaisyUI toast, and a setTimeout flips it back to false after a few seconds — nicer than a blocking alert().<textarea> for the long "about" field and a dropdown for gender — small UX wins over a row of plain inputs.The store is in-memory. Re-fetch the user from the cookie on app load (in Body) to persist the session.
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.
You hit the logout API but forgot dispatch(removeUser()). Clear the store too.
The real server error is at err.response.data. err.message just says "request failed with status 400."
Rendering the feed card before data arrives. Guard with {feed && ...}.
A DaisyUI toast with a timeout is non-blocking and looks like a real product; alert() halts everything.
/profile/view) with the cookie; if it returns the user, dispatch it back into the store. The session is restored without the user noticing./login. Since Body wraps every route, any URL accessed while logged out lands on login.<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.dispatch(removeUser()), then navigate to login. Forgetting the store-clear leaves the UI showing the logged-in user until the next refresh.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.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.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(); }, []);
res.data.data (axios's data wraps the server's { data: [...] }). Log the response once to confirm where the array actually lives.<Link to="/connections"> in the navbar dropdown.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> ); });
!connections handles "still loading / null" (return nothing); length === 0 handles "loaded but none" (show a friendly message). They're different and both matter.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.{age && gender && ...} avoids rendering "undefined, undefined" when those fields are missing.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... });
fromUserId: a connection request references who sent it. Destructure from request.fromUserId, not request directly — otherwise everything renders undefined.requestSlice with addRequests, fetched from /user/requests/received, dispatched and read with useSelector — identical to connections.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>
"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.{} and the third is the credentials config. Skipping the empty body would misplace withCredentials._id (the request record), not the user's id — a common mix-up that makes the call fail.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),
state.filter(r => r._id !== id) — everything except the one just handled. The reducer returns the new list, the store updates, the UI follows.showButtons state 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.
Wrong path — it's /user/connections, not /connections. Check the exact route the backend exposes.
Reading the wrong level. The request's user is under request.fromUserId, not request.
Every mapped element needs key={_id}. Use a stable unique id, not the index.
onClick={reviewRequest(...)} calls it immediately. Wrap it: onClick={() => reviewRequest(...)}.
You hit the API but didn't update the store. Dispatch removeRequest(_id) after the call.
axios wraps the body in .data; if the server also wraps its payload in data, you reach the array at res.data.data.
useEffect, dispatch the result into the store, and render it with useSelector. Connections and requests both follow this exact pattern.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.null, and show a friendly "none found" message when it's an empty array (length === 0).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.state.filter(r => r._id !== id)). The component reads the store, so it re-renders with that card removed — instant, no extra network call.fromUserId, so you must destructure from request.fromUserId. Inspect the actual response shape to find where the data lives.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),
/request/send/:status/:userId. The card disappears because the slice filters it out — no refetch._id: the feed card already has the user object, so extract _id and hand it to the handler (and to removeUserFromFeed).feed is an empty array — check feed.length === 0 and show "No new users found" so accessing feed[0] doesn't crash.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>
isLoginForm. Toggling it re-renders the form in the other mode.{!isLoginForm && (...)} requires wrapping them in <>...</> — JSX allows only one returned parent.onClick={() => setIsLoginForm(v => !v)}, not onClick={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.
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 });
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."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.
Set the password field to type="password" so it's masked — easy to forget while building.
Seed edit fields like age with user.age || "" so a missing value doesn't flip the input uncontrolled and warn.
Change <title> in index.html from "Vite + React" to "DevTinder"; add meta description/keywords for SEO.
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.
The backend created the user but never set the cookie. Generate the JWT and res.cookie(...) on signup too.
Missing await on the axios call — res is still a pending promise. Await it.
You called the setter directly in onClick. Wrap it: onClick={() => setIsLoginForm(v => !v)}.
Acting on the last card empties the array. Guard feed.length === 0 before reading feed[0].
A null user.age makes the input uncontrolled. Default it to "".
Separate sessions = two logged-in users, perfect for testing request flows end-to-end.
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.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).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.