Foundations · Part II
Finding the path — client-side routing, dynamic pages, the effect-dependency rules, custom hooks, and keeping data fresh as the app grows.
The notes continue — from a single page into a real multi-page app: routing, dynamic URLs, and the hook rules that keep state predictable. Basics kept brief, gotchas surfaced, interview Q&A in every section.
A React app stays a single page. Routing doesn't fetch new HTML — it just swaps components in and out as the URL changes, so navigation is instant and the shell (header, footer) never reloads. #
useEffect, deeper: the three timing cases#
The same hook from before, but the second argument (the dependency array) is what decides when the effect runs. There are exactly three cases, and interviewers love this one.
After every render
Leave the array out entirely and the effect runs after each render — usually not what you want.
Once, on first render
Runs a single time after the initial render. This is where data fetching belongs.
When that value changes
Runs on first render, then again only when a listed value changes (e.g. search text).
// 1 - runs after every render useEffect(() => { ... }); // 2 - runs once, after the first render useEffect(() => { ... }, []); // 3 - runs whenever btnName changes useEffect(() => { ... }, [btnName]);
- The callback is required; the array is optional. Adding the array is what changes the timing — it's a filter on "when should this re-run."
-
Why fetch with
[]: you want the API call once, not on every keystroke or click. An empty array says "set this up after the component first appears, then leave it alone."
useState, the rules: call hooks at the top, never inside conditions#
State variables belong inside
a component function, declared at the very top — before any logic. Putting a hook inside an if, a loop, or a nested function breaks React.
- Inside the component only: declaring state outside the function throws "Invalid hook call — hooks can only be called inside the body of a function component." State is local to a component, so it has to live there.
-
At the top:
declare all your
useState/useEffectcalls first, before branching or returning. This keeps their order identical on every render. -
Never in
if/for/nested functions: a hook inside a condition might run on some renders and skip on others. React tracks hooks by call order, so a changing order corrupts which state belongs to which variable.
Think of hooks like numbered seats on every render. React hands out state by seat order: seat 1, seat 2, seat 3. If a hook hides behind an if, the seats shift on the next render — and React hands the wrong state to the wrong variable. Keep them at the top, always in the same order, and the seating never changes.
Routing needs a library: React Router DOM#
React itself doesn't do URLs — it renders components. To map URLs to components you add a routing library; the standard choice is React Router DOM, installed like any package.
// install it like any npm package
npm install react-router-dom
-
It's just another dependency:
installing adds it to
package.jsonandpackage-lock.json, same as anything else. - Version matters: the v6 API (used here) is different from v5 — older tutorials and projects may show the v5 style, so check the version before copying code.
The routing config: createBrowserRouter + RouterProvider#
You describe your routes as a plain list of objects — each says "for this path, render this element." Then you hand that config to a provider instead of rendering a single component directly.
import { createBrowserRouter, RouterProvider } from "react-router-dom"; const appRouter = createBrowserRouter([ { path: "/", element: <AppLayout />}, { path: "/about", element: <About />}, { path: "/contact", element: <Contact />}, ]); // instead of rendering <AppLayout/>directly: root.render(<RouterProvider router={appRouter} />);
Builds the config
Takes an array of route objects (each a path
+ element
) and returns a router.
Wires it in
A component that takes your router via the router
prop and actually renders the matched route.
-
The config is data, not code branches:
a list of
{ path, element }objects — React Router reads it and decides what to show for the current URL. -
Other router types exist
(
createHashRouter, memory, static) for special cases, butcreateBrowserRouteris the recommended default for web apps.
Custom error pages: errorElement + useRouteError#
Hit an unknown URL and React Router shows its own error screen. You can replace it with your own by adding an errorElement, and read what went wrong with the useRouteError
hook.
{ path: "/", element: <AppLayout />,
errorElement: <Error />}
// inside the Error component:
const err = useRouteError();
return <h3>{err.status}: {err.statusText}</h3>;
-
errorElementrenders whenever a route throws or the URL doesn't match — so users see a friendly page, not React's raw red crash screen. -
useRouteError()hands you an error object (status,statusText, etc.) so you can show "404 — Not Found" instead of a generic message. -
Spot a hook by its name:
anything starting with
useis a hook — a function with a special purpose, by convention.
Nested routes & the <Outlet>#
You usually want the header and footer to stay put while only the middle swaps per page. You do that by nesting routes as children
of a layout, and marking where the child should appear with <Outlet />.
const appRouter = createBrowserRouter([ { path: "/", element: <AppLayout />, children: [ { path: "/", element: <Body />}, { path: "/about", element: <About />}, { path: "/contact", element: <Contact />}, ], }, ]); // AppLayout renders the shell + an outlet const AppLayout = () => ( <div><Header /><Outlet />{/* child route renders here */} </div>);
<Outlet />
is a picture frame
built into your layout. The frame stays on the wall (header above, footer below); React Router just slides a different photo into it depending on the URL. The frame itself never shows up in the final HTML — it's replaced by whichever child matched.
-
The parent stays mounted:
the layout (and its header) renders once; only the
<Outlet />contents change per route. That's how the header survives navigation. -
You won't find
<Outlet>in the DOM: it's a placeholder — the matched child component takes its place in the rendered HTML.
<Link>vs <a>: don't reload the whole page#
An anchor tag works, but it reloads the entire app on every click — slow, and it throws away your loaded state. Inside React, navigate with <Link>
instead.
<Link to>
From React Router DOM. Swaps components client-side —no full reload, header stays, navigation is instant. Use this for in-app navigation.
<a href>
The browser's anchor. Triggers a full page reload — refetches everything, logo flickers, state resets. Fine for external links, wrong for in-app routes.
-
toinstead ofhref: the only syntax difference.<Link to="/about">About </Link>. -
Under the hood,
<Link>still renders an<a>— the browser only understands anchors. React Router wraps it, intercepts the click, and updates the view without a reload.
SPA: client-side vs server-side routing#
Because <Link>
only swaps components, the app is a single page application
— loaded once, then components interchange. That's client-side routing, and it's different from the old way.
Client-side routing
All page components ship on first load. Navigating just renders a different component —no network call for the page. Calls happen only when you fetch data.
Server-side routing
The old model: about.html, contact.html. Each click fetches a fresh HTML file from the server and reloads the whole page.
- Don't confuse server-side routing with server-side rendering: routing is "where do pages come from on navigation"; rendering (SSR) is "where the HTML is first generated." Different topics.
Dynamic routes: one component, many URLs#
You don't write a route per restaurant. You write one route with a variable segment — marked with a colon — and one component that loads whatever id is in the URL.
{ path: "/restaurants/:resId",
element: <RestaurantMenu />}
// /restaurants/425 -> resId = "425"
// /restaurants/229 -> resId = "229"
-
:resIdis a placeholder: the colon tells React Router this part of the path is dynamic. Any value slots in and the same<RestaurantMenu />renders. - One reusable component, dynamic data: the markup is identical for every restaurant — only the data loaded inside it changes. That's reusability.
Reading the URL param: useParams#
To fetch the right data, the component needs the id from the URL. The useParams
hook gives you an object of the dynamic segments; destructure the one you named.
import { useParams } from "react-router-dom"; const { resId } = useParams(); useEffect(() => { fetchMenu(); }, []); const fetchMenu = async() => { const data = await fetch(MENU_API + resId); const json = await data.json(); setResInfo(json.data); };
-
The key matches the route:
you named it
:resIdin the path, souseParams()returns{ resId }. Destructure it on the fly. -
Build the API URL from the param:
MENU_API + resId— same component, different restaurant, just by changing the id in the URL. - Keep API URLs in a constants file rather than hard-coded in the component — cleaner and reusable.
Rendering the menu: state, shimmer, early return, map + keys#
Fetch into state, show a shimmer until it arrives, then render the list. Two traps appear here — reading into null, and rendering a list without keys.
const[resInfo, setResInfo] = useState(null); // early return - guards against null before render if (resInfo === null) return <Shimmer />; const { name, cuisines } = resInfo?.cards[0]?.card?.card?.info; const itemCards = resInfo?.cards[2]?.groupedCard?.cardGroupMap ?.REGULAR?.cards[1]?.card?.card?.itemCards; return ( <ul>{itemCards.map((item) =><li key={item.card.info.id}>{item.card.info.name} </li>)} </ul>);
-
Early return beats a ternary here:
if
resInfoisnull, return the shimmer before any code tries to read.nameoff null. A ternary in the JSX still runs the destructuring above it — and crashes. -
Always pass
keyin a.map(): without it React warns "each child in a list should have a unique key." Use a stable id, never the array index. -
Optional chaining everywhere in deep data:
live JSON is deeply nested and shifts over time —
?.stops one missing field from crashing the whole render. - Live APIs change: the exact card indices and field names drift. Don't panic — open the network tab, find the current API and shape, and adjust the path. The knowledge transfers even when the data doesn't.
Gotchas & nice-to-knows#
Hook inside a condition
Calling useState
/useEffect
inside an if, loop, or nested function corrupts hook order. Always call them at the top level.
Anchor tag reloads everything
Using <a>
for in-app links does a full page reload and resets state. Use <Link>
instead.
Reading data off null
Destructuring before the fetch returns crashes. Guard with an early return (if (x === null) return <Shimmer/>
).
Missing key in map
Lists without a unique key
trigger a React warning and can cause wrong re-renders. The key goes on the outermost mapped element.
Link is an anchor underneath
In the DOM, <Link>
renders as <a>
— React Router just intercepts the click to avoid a reload.
GraphQL fights over-fetching
This menu API returns far more than the UI uses. GraphQL lets the client ask for exactly the fields it needs — less over/under-fetching.
Interview Q&A#
Q. When does useEffect run, and how does the dependency array change that?
[]
it runs once, after the first render. With values inside [a, b]
it runs on the first render and again whenever one of those values changes. The array is a filter on when
to re-run.
Q. Why must hooks be called at the top level, never inside conditions or loops?
if
or loop, that order can change between renders, and React hands the wrong state to the wrong variable. Calling them all at the top keeps the order stable.
Q. What is a single page application (SPA)?
Q. Difference between client-side and server-side routing?
Q. Why use <Link>instead of an anchor tag in React?
<Link>
navigates client-side — it swaps components without a reload, so the header stays and it's instant. It renders as an <a>
underneath, but React Router intercepts the click.
Q. How do you build a dynamic route and read its parameter?
/restaurants/:resId, and point it at one component. Inside that component, call useParams()
— it returns an object with the named params — and destructure resId
to fetch the right data.
Q. What does <Outlet>do?
<Outlet />
contents change as the URL changes. It doesn't appear in the final DOM — the child replaces it.
Q. How do you handle a route error or a 404 in React Router?
errorElement
to the route — it renders whenever the route throws or the URL doesn't match. Inside that component, call useRouteError()
to read the error's status
and statusText
so you can show a real message like "404 — Not Found."
One-line recall#
Class components are the older way to write React — you rarely build with them now. But they expose the lifecycle hooks were built to replace, and that's the real prize: understanding when a component mounts, updates, and unmounts. #
Why learn class components at all?#
You won't reach for them in new code — functional components with hooks are the modern standard. Three reasons to know them anyway.
Asked a lot
Many companies maintain older codebases built on class components, so interviewers test them.
Still out there
You'll meet class components in existing projects and need to read and edit them.
The lifecycle
They make React's mount/update/unmount phases explicit — which sharpens how you understand hooks.
The syntax: a class with a render method#
A functional component is a function that returns JSX. A class component is a class that extends React.Component
and has a render()
method returning JSX. That's the core difference.
import React from "react"; // functional: a function returning JSX const User = () => return <div>...</div>; // class: extends React.Component, render() returns JSX class UserClass extends React.Component { render() { return <div>...</div>; } }
-
extends React.Componentis what tells React "this is a class component" so it tracks and manages it.React.Componentis a base class you inherit from, imported fromreact. -
Import styles vary:
you'll see
extends React.Componentor, with a named import,extends Component. Same thing — don't let the two forms confuse you. - Import/export is identical to functional components — nothing special needed to use a class component once it's defined.
Props in a class: constructor + super(props)#
Passing props is the same. Receiving them differs: a class takes them in its constructor, calls super(props), and then reads them anywhere via this.props.
// passing - same as functional <UserClass name="Akshay" location="Dehradun" />class UserClass extends React.Component { constructor(props) { super(props); } render() { const { name, location } = this.props; return <h2>{name} —{location}</h2>; } }
-
All props arrive as one object:
everything you pass is bundled into a single
propsobject handed to the constructor. -
Read with
this.props: usethis.props.nameanywhere in the class, or destructure for cleaner code — the same on-the-fly destructuring you use in functional components. -
Why
super(props): you're extending a base class, so you must call its constructor first — and passingpropsup is what makesthis.propsavailable inside the constructor. (Classic interview "why" question.)
State in a class: this.state & this.setState#
Where functional components use useState, a class keeps all
its state in one object, this.state, set up in the constructor. You update it only through this.setState
— never by assignment.
constructor(props) { super(props); this.state = { count: 0, count2: 2, // one object holds every state variable }; } // update - NEVER this.state.count = ... directly this.setState({ count: this.state.count + 1 });
-
One object, many variables:
unlike multiple
useStatecalls, a class groups all state into the singlethis.stateobject. (Behind the scenes, React keeps functional state as one object too.) -
Never mutate directly:
this.state.count = 5won't re-render and creates inconsistencies — the class equivalent of the "don't reassign state" rule. Always go throughsetState. -
setStatemerges, not replaces: pass only the keys you're changing and React leaves the rest untouched. It diffs, updates those variables, and re-renders. You can batch several keys in one call.
The mounting lifecycle: constructor → render → componentDidMount#
When a class component loads (we say it mounts — an instance of the class is created), three methods run in a fixed order.
Set up state & props
Return JSX
React paints to the page
Runs after it's on screen
-
Mounting = creating an instance:
"loading a class component" means a new instance of the class is created, which is why the
constructorruns first (constructors run on instantiation, not invocation). -
componentDidMountruns last, only after React has actually put the component on the DOM — hence the name. That timing is the whole point of the next section.
Render phase vs commit phase (and why React is fast)#
React splits mounting into two phases. This is the key to the trickiest interview question: the order of lifecycle calls when a parent has multiple children.
Render phase
Runs constructor
+ render. Pure JavaScript on the virtual DOM — computes what changed (the diff). Fast, and React batches
this across siblings.
Commit phase
React actually updates the real DOM, then fires componentDidMount. DOM manipulation is expensive, so React batches all commits together.
// parent with two class children, the real order: parent constructor parent render first child constructor first child render // render phase batched second child constructor second child render first child componentDidMount // commit phase batched second child componentDidMount parent componentDidMount
-
The surprise:
the first child's
componentDidMountdoes not fire right after its render. React runs the render phase for all children first, then the commit phase for all of them. - Why batch: updating the real DOM is the slowest part. By computing all the diffs first (cheap, in-memory virtual DOM) and committing once, React minimizes expensive DOM work — that's a big reason it's fast.
Think of a print shop. The render phase is laying out every page on screen — quick, all done together. The commit phase is the actual printing — slow and expensive, so you batch every page into one print run instead of walking to the printer after each page. React lays out all children first (render), then prints them all at once (commit).
componentDidMount = where API calls go#
In a class, you fetch data in componentDidMount
— the same role useEffect(fn, [])
plays in functional components. The reason is the two-phase timing.
async componentDidMount() { const data = await fetch(GITHUB_API); const json = await data.json(); this.setState({ userInfo: json }); }
-
Render fast, then fill:
the component mounts immediately with default/empty state (so the user sees something), then
the API call runs and
setStatere-renders with real data. React doesn't wait on the network to paint. -
Why after
mount:
because
componentDidMountfires once the DOM is already on screen — the ideal moment to kick off a fetch without blocking the first paint.
The update cycle: setState → render → componentDidUpdate#
After mounting, any setState
starts an update
cycle: React re-runs render
(not the constructor — that runs once), updates the DOM, then calls componentDidUpdate.
componentDidUpdate(prevProps, prevState) { // the old pain: manual guards to avoid infinite loops if (this.state.count !== prevState.count) { // run effect for count change } }
-
Constructor runs once; render runs every update.
Mounting fills state with defaults; each
setStatere-renders with the new values and then firescomponentDidUpdate. -
This is where hooks shine:
the messy
prevProps/prevStatecomparisons (one per variable, easy to get wrong) collapse into one dependency array —useEffect(fn, [count]). That's the pain the array was designed to remove.
componentWillUnmount: clean up your mess#
Just before a component leaves the screen, componentWillUnmount
runs. In a single-page app the page never reloads, so anything you started — timers, subscriptions — keeps running unless you stop it here.
componentDidMount() { this.timer = setInterval(() => { console.log("still running…"); }, 1000); } componentWillUnmount() { clearInterval(this.timer); // stop the leak }
-
The SPA leak:
a
setIntervalstarted incomponentDidMountkeeps firing after you navigate away — and starts a new one each time you return. Several stale timers pile up invisibly and degrade performance. -
The fix:
store the timer id on
this(shared across all the class's methods) incomponentDidMount, andclearIntervalit incomponentWillUnmount. Create a mess, clean the mess. -
Hooks equivalent:
return a cleanup function from
useEffect— it runs on unmount, doing the same job ascomponentWillUnmount.
// functional cleanup - the modern equivalent useEffect(() => { const timer = setInterval(..., 1000); return () => clearInterval(timer); // runs on unmount }, []);
Gotchas & nice-to-knows#
Mutating state directly
this.state.count = 5
won't re-render. Always use this.setState({...}).
Forgetting super(props)
Skip it and this.props
is undefined in the constructor. Always call super(props)
first.
Uncleaned timers in an SPA
Intervals/subscriptions outlive the component and stack up. Clear them in componentWillUnmount
(or a useEffect
cleanup).
Expecting didMount per-child immediately
With multiple children, all renders fire before any componentDidMount
— render phase is batched.
Don't equate useEffect & didMount
They overlap in use, but useEffect
is not "componentDidMount under the hood" — it's a different mechanism. Don't memorize them as equal.
this is shared across methods
Anything on this
(like this.timer
) is reachable from every method of the class.
Interview Q&A#
Q. What's the difference between a functional and a class component?
React.Component
and returns its JSX from a render()
method. Functional components with hooks are the modern standard; class components are the older style still found in legacy code.
Q. How do you read props and state in a class component?
super(props)
and then read them anywhere via this.props. State lives in one object, this.state, set in the constructor, and you read it via this.state.x. You can destructure both for cleaner code.
Q. Why must you use setState instead of assigning to this.state?
this.state.count = 5
) doesn't tell React anything, so nothing re-renders and you get inconsistent state. setState
updates the state, diffs it, and triggers a re-render. It also merges — it only changes the keys you pass.
Q. What is the order of lifecycle methods on mount?
componentDidMount. Constructor and render are the render phase; the DOM update and componentDidMount
are the commit phase.
Q. With a parent and multiple children, why doesn't each child's componentDidMount fire right after its render?
componentDidMount
in the commit phase. It does this because updating the real DOM is expensive, so it computes all changes first and commits them together.
Q. Why are API calls made in componentDidMount?
setState
to fill in the data. Since componentDidMount
runs after the DOM is painted, it's the right moment to start a fetch without blocking the first render.
Q. What is componentWillUnmount for?
clearInterval(this.timer). In functional components, the cleanup function returned from useEffect
does the same job.
Q. How do these class lifecycle methods map to useEffect?
[]
covers the "run once after mount" role, a populated dependency array covers "run on change," and a returned cleanup function covers unmount. But don't claim they're identical —useEffect
is its own mechanism, not class lifecycle methods in disguise.
One-line recall#
Two levers separate a junior from a senior build: give each piece one job (custom hooks pull logic out of components), and stop shipping one giant bundle —split the code so each route loads only when needed. #
Single Responsibility & modularity#
Each component (it's just a function) should do one thing well. If a component is doing several jobs, break it into smaller pieces — that's modularity.
-
One job per component:
a
RestaurantCardtakes props and renders a card — nothing more. A reader should grasp it at a glance. - Why it pays off: small, focused pieces are reusable (drop the card anywhere), maintainable (find code fast), and testable (write a test per unit, so a bug is caught in the small piece, not hunted across a giant component).
- No hard rule: there's no exact line for "too much" — the aim is keeping components light and readable for the next person.
Custom hooks: a hook is just a function#
A hook is a normal JavaScript helper function with React powers (it can hold state, run effects). You've used library hooks (useState, useEffect, useParams
) — you can write your own the same way.
-
Don't panic at the word "hook":
it's a utility function. Your custom hook is no different from
useParams— someone just wrote logic inside it and returned a value. - The point: move a chunk of logic out of a component and into a hook, so the component gets lighter and the logic becomes reusable.
Building useRestaurantMenu#
The menu component had two jobs: fetch the data and display it. Pull the fetching into a hook so the component is left with one job — displaying.
// utils/useRestaurantMenu.js import { useState, useEffect } from "react"; import { MENU_API } from "../utils/constants"; const useRestaurantMenu = (resId) => { const[resInfo, setResInfo] = useState(null); useEffect(() => { fetchData(); }, []); const fetchData = async() => { const data = await fetch(MENU_API + resId); const json = await data.json(); setResInfo(json.data); }; return resInfo; // the contract: in resId, out resInfo }; export default useRestaurantMenu;
// the component is now clean - one job: display const resInfo = useRestaurantMenu(resId);
- Think in contracts: before writing a hook, fix its input and output. Here: in a restaurant id, out the restaurant info. The rest is implementation the caller never sees.
-
It can hold its own state:
a hook uses
useState/useEffectjust like a component — React tracks it the same way. -
Convention:
one hook per file in a
utilsfolder, named after the hook.
A custom hook is like a vending machine. You put in a coin (the resId
) and a snack comes out (the resInfo
). You don't care about the motors and sensors inside — just the slot in and the tray out. The component only knows the contract, not the machinery.
Building useOnlineStatus#
A second hook to cement the pattern: report whether the user is online. Its contract takes no input and returns a boolean — useful for online/offline dots, "you're offline" screens, or an offline mini-game.
const useOnlineStatus = () => { const[online, setOnline] = useState(true); useEffect(() => { window.addEventListener("offline", () => setOnline(false)); window.addEventListener("online", () => setOnline(true)); }, []); return online; };
// use it anywhere, in one line const online = useOnlineStatus(); return online ? <Cards />: <h1>You're offline </h1>;
-
Browser event listeners:
windowfiresonlineandofflineevents. Register them once inside auseEffectwith[], flip the state, return it. - Reusability in action: the same one-line hook powers the offline screen in the body and a green/red status dot in the header — write once, use anywhere.
- Tip: test offline behavior with the browser's Network tab "Offline" / throttling toggle, not by killing your Wi-Fi.
The use prefix & why it matters#
Start every hook's name with use
(lowercase). It isn't strictly required, but it's strongly recommended — and most projects' linters enforce it.
-
Signals "this is a hook":
the
useprefix tells React (and your teammates) the function follows hook rules and may hold state/effects — not a plain helper likegetOnlineStatus. -
Linters enforce it:
a React-configured ESLint will error if you skip
use. Follow the convention the library recommends rather than fighting it.
The bundle problem: one huge JS file#
A bundler (Parcel here) takes all your files and bundles them into one JavaScript file the browser loads. Fine for a small app — but as components grow into the thousands, that single file balloons and slows the first load.
- One file ships everything: home, about, contact, every route's code — all in one bundle, even though the user may never visit most of it.
- Neither extreme works: one giant file is too heavy to download; thousands of separate files means too many browser requests. The answer is something in between.
Code splitting: many names, one idea#
Break the app into smaller bundles loaded on demand. You'll hear this called many things — they all mean the same thing.
- Split along features: bundle by logical area — e.g. a food-delivery chunk and a separate grocery chunk — so each is a small app inside the big app.
- Why it's a senior-level topic: in front-end system-design interviews, knowing when and how to split bundles is exactly what signals you can build large, performant apps.
lazy() + Suspense in practice#
Replace a normal import with React's lazy, which takes a function that dynamically import
s the component. React then loads that code only when the route is visited — and you wrap it in Suspense
to show a fallback while it loads.
import React, { lazy, Suspense } from "react"; // not a normal import - loaded on demand const Grocery = lazy(() => import("./components/Grocery")); // wrap it so React has something to show while it loads { path: "/grocery", element: ( <Suspense fallback={<h1>Loading…</h1>}><Grocery /></Suspense>) }
-
What it does:
the main bundle no longer contains the grocery code. Visiting
/grocerytriggers a separategrocery.jsrequest — the code arrives only on demand. -
Why Suspense is required:
while the chunk is downloading, the component isn't there yet. React "suspends" rendering and throws an error unless you provide a
Suspenseboundary with afallbackto show meanwhile (a "Loading…" message or a shimmer). - The win: a few lines keep the initial bundle small, so a large app loads fast. Lower bundle size is the whole goal — this is how you fight app "bloat."
Gotchas & nice-to-knows#
lazy import without Suspense
A lazy component with no Suspense
wrapper throws "a component suspended while responding to synchronous input." Always wrap it.
Wrong JSON path in the hook
Moving fetch logic into a hook still needs the right shape — e.g. return json.data, not json, or you get undefined.
Forgetting the use prefix
Skipping use
works at runtime but trips React-configured linters and hides that it's a hook.
import() is a function
Inside lazy, import("./X")
is the dynamic-import function
(returns a promise) — not the static import
statement.
The Chrome dino game
The offline game is just online-status detection plus a fallback UI — the exact pattern useOnlineStatus
enables.
Split by feature, not file
Good chunks map to features (flights, hotels, grocery), each holding enough code to stand alone.
Interview Q&A#
Q. What is the Single Responsibility Principle in React?
Q. What is a custom hook and why use one?
use
prefix, that can hold state and effects. You write one to pull logic (like data fetching) out of a component — so the component stays focused on display and the logic becomes reusable and testable.
Q. How do you start writing a custom hook?
useRestaurantMenu
the input is a restaurant id and the output is the restaurant info; for useOnlineStatus
there's no input and the output is a boolean. Once the contract is clear, the implementation follows.
Q. Why prefix a hook's name with "use"?
Q. What problem does code splitting solve?
Q. Name some other terms for code splitting.
Q. How do you lazy-load a component in React?
lazy(() => import("./Component"))
instead of a normal import. React then loads that code only when the component is needed, producing a separate bundle.
Q. Why do you need Suspense with lazy?
Suspense
with a fallback
gives React something to show meanwhile, like a loading message or a shimmer.
One-line recall#
There's no single "right" way to style React — plain CSS, preprocessors, styled-components, component libraries, or utility CSS. This guide picks Tailwind: instead of writing CSS in a separate file, you compose utility classes right in your JSX. #
The ways to style a React app#
Before picking one, know the landscape. Each approach styles the same UI — the differences are in ergonomics, scale, and team preference.
Class + stylesheet
Write a class in JSX, define it in a .css
file. Simple, but doesn't scale cleanly on big apps.
CSS with superpowers
Adds variables, nesting, and more to CSS. Handy, but not the common choice in modern production React.
CSS-in-JS
Write styles inside JS, scoped to a component. Popular at large companies (e.g. used at Uber).
Pre-built & styled
Material UI, Bootstrap, Chakra UI, Ant Design — import a ready-made <Button>
that's already beautiful, no CSS needed.
Utility-first
Compose tiny single-purpose classes in your markup. Trending, and the pick here.
It's a choice
All do the same job. Try a few and form your own opinion — there's no universally "correct" answer.
What "utility-first" means#
Tailwind's pitch: style your components without leaving your JSX. Rather than naming a class and writing its CSS elsewhere, you apply many small classes — each does one CSS thing — directly in the markup.
Normal CSS
Add class="logo"
in JSX, switch to index.css, write .logo { width: 14rem; }. Two files, back and forth.
Tailwind
Write className="w-56"
right where you build the element. The styling lives with the markup — no file switching.
-
One class = one rule:
flex→display:flex,w-56→width:14rem,p-4→padding:1rem. You compose the look from small pieces. - Same mental model as CSS: you still think "make this flex, add padding, set width" — you're just expressing it as class names instead of a stylesheet.
Setting up Tailwind with Parcel#
Tailwind isn't React-specific — it works with any framework or plain HTML. Setup with Parcel is a few steps; follow the framework guide that matches your bundler.
npm i tailwindcss postcss
npx tailwindcss init
.postcssrc + content paths
@tailwind in index.css
/* index.css - the only CSS you write */
@tailwind base;
@tailwind components;
@tailwind utilities;
// tailwind.config.js - where Tailwind looks for classes content: ["./src/**/*.{html,js,jsx,ts,tsx}"],
-
PostCSS does the work:
Tailwind runs on PostCSS (a tool that transforms CSS via JavaScript). The
.postcssrcfile tells your bundler to read Tailwind — you don't have to learn PostCSS separately. -
The
contentarray lists where your classes live so Tailwind knows which files to scan. List only the file types you actually use. -
Those three
@tailwindlines are the entire stylesheet — after adding them, you may never touch the CSS file again.
The class nomenclature#
Tailwind's class names follow consistent shorthand. Learn the pattern once and most classes become predictable.
m / p + side + size
m-4
margin, p-4
padding, px-4
left+right, py-2
top+bottom, mb-2
margin-bottom. Numbers map to rem (4 = 1rem).
flex utilities
flex, flex-wrap, justify-between, items-center
— flexbox without the verbosity.
w- / h-
w-56
sets width (14rem). Sizes come from a scale.
bg- / text- + shade
bg-pink-100, bg-gray-400
— shades run 50 → 950; higher = bolder/darker.
font / text / shadow / rounded
font-bold, text-lg, shadow-lg, rounded-lg. Sizes use sm/md/lg/xl/2xl.
hover: prefix
hover:bg-gray-400
applies on hover. Same idea for focus, responsive breakpoints, etc.
Styling in practice & arbitrary values#
You build a layout by stacking utilities. And when the scale doesn't have the exact value you need, escape to a custom one with square brackets.
// a header laid out with utilities <div className="flex justify-between items-center shadow-lg" ><img className="w-56" /><ul className="flex" ><li className="px-4" >Home </li></ul></div> // arbitrary value when no class fits exactly <div className="w-[200px]" >...</div>
-
Compose, don't author:
a card is just
m-4 p-4 w-[250px] rounded-lg shadow-lg— assembled from utilities, no separate CSS rule. -
Arbitrary values:
w-[200px]sets an exact size the scale doesn't offer. Use the built-in scale by default; reach for brackets only when you truly need a specific value. - You're not handicapped: hover states, complex layouts, even intricate components are all doable — there's a class for essentially everything.
The IntelliSense extension#
The Tailwind CSS IntelliSense VS Code extension is what makes utility-first practical — you don't memorize classes, you get suggestions as you type.
-
Autocomplete:
start typing
pinkorshadowand it lists matching classes — no trips to the Tailwind website. -
Hover to inspect:
hover any class to see the exact CSS it generates (e.g.
w-56→width: 14rem). - If suggestions stall: trigger them manually with Ctrl +Space .
Pros, cons & why it stays lightweight#
Tailwind is a tradeoff. The wins are speed and a tiny CSS bundle; the costs are a learning curve and busier markup.
No file switching
Style right in the JSX — faster iteration, no jumping between markup and stylesheets.
Tiny, purged bundle
The build includes only
the classes you actually used — reuse m-4
100 times and it ships once. Unused classes (e.g. shadow) never reach the browser.
Learning curve
Remembering the class names takes time up front; it gets fast once the patterns stick.
Busy markup
Heavily-styled elements grow long className
strings, which can make JSX harder to read.
Utility classes are like LEGO bricks. Plain CSS is sculpting a custom piece in another room (the stylesheet); Tailwind hands you a bin of standard bricks you snap together right where you're building. Faster to assemble — but pour out enough bricks and the table (your markup) gets cluttered.
Gotchas & nice-to-knows#
Classes outside content paths
If a file isn't in the content
array, its Tailwind classes are purged out and won't apply. Keep the globs accurate.
Padding all sides vs one
p-4
pads every side. For a button you usually want px-4 py-2
— mixing axes, not all-around.
Full-width buttons
A block element stretches to its parent. Wrap it in a div
(or set width) to size a button naturally.
Numbers map to rem
The spacing scale is rem-based: 4
= 1rem, 2
= 0.5rem (8px). Consistent across m/p/w/h.
Not React-only
Tailwind works with Angular, plain HTML, anything — it's a generic CSS framework.
Pre-built components exist
Tailwind UI offers ready-made component markup to copy — like a component library, but as utility classes.
Interview Q&A#
Q. What are the main ways to style a React app?
Q. What is Tailwind CSS and how is it different from normal CSS?
Q. What is PostCSS's role with Tailwind?
.postcssrc
so your bundler understands Tailwind — but you don't need to learn PostCSS itself to use Tailwind.
Q. What does the content array in tailwind.config do?
Q. Why is Tailwind's output so lightweight?
shadow, that CSS never ships; if you use m-4
a hundred times, it's included once. That keeps the CSS bundle small.
Q. How do you apply a value that isn't in Tailwind's scale?
w-[200px]. Prefer the built-in scale for consistency, and reach for brackets only when you genuinely need an exact, off-scale value.
Q. What are the tradeoffs of using Tailwind?
Q. How do you handle hover or responsive styles in Tailwind?
hover:bg-gray-400
applies on hover, and breakpoint prefixes apply at certain widths. The same utility just runs conditionally.
One-line recall#
Every React app is two layers: a UI layer (your JSX) powered by a data layer (state, props, variables). Senior-level React is mostly about the data layer — who owns state, how it flows, and how to share it without threading props through every level. #
The UI layer & the data layer#
A useful mental model: the UI layer is the static JSX you render, and the data layer (state, props, local variables) is what drives it. The UI is powered by the data — get the data layer right and the UI follows.
- UI is "dumb": JSX just displays what it's given. All the logic — what to show, when it changes — lives in the data layer.
- Why it matters: managing data well is what separates a junior build from a senior one. Most of this guide is about handling that data layer cleanly.
Higher Order Components (HOCs)#
A higher order component is a function that takes a component and returns a new, enhanced component. It doesn't change the original — it wraps it to add features (a label, extra props, behavior).
// contract: in a component, out an enhanced component const withPromotedLabel = (RestaurantCard) => { return (props) => ( <div><label className="absolute" >Promoted </label><RestaurantCard {...props} /> // pass props through </div>); }; // build the enhanced version, use it conditionally const RestaurantCardPromoted = withPromotedLabel(RestaurantCard); {resData.info.promoted ? <RestaurantCardPromoted resData={resData} />: <RestaurantCard resData={resData} />}
- It's just a function: takes a component in, returns a component out. The returned thing is itself a functional component returning JSX.
-
Keep it pure:
an HOC doesn't modify the input component's code — it wraps and enhances it. Spread the props (
{...props}) down so the wrapped component still receives everything it needs. - Use it for: reusable enhancements — adding a label, injecting props, layering behavior — without duplicating the base component.
Controlled vs uncontrolled components#
A component is uncontrolled when it manages its own state, and controlled when its parent drives it through props. There's no strict definition — it's about where the source of truth lives.
Uncontrolled
The component holds its own state (e.g. its own showItems
) and decides for itself. The parent can't coordinate it.
Controlled
The parent passes the value in as a prop (e.g. showItems={...}
) and the component just follows. The parent is the source of truth.
- Why make it controlled: when sibling components need to coordinate — like an accordion where opening one closes the others — a single owner (the parent) must decide who's open.
- It's a spectrum: a controlled component can still have minor local state; what matters is that the main state driving it comes from the parent.
Lifting the state up#
To let a parent coordinate its children, move the state out of the children and up into the parent. The parent holds one piece of state and passes both the value and a setter down.
// parent (RestaurantMenu) owns which one is open const[showIndex, setShowIndex] = useState(null); {categories.map((cat, index) => ( <RestaurantCategory data={cat} showItems={index === showIndex} // controlled setShowIndex={() => setShowIndex(index)} // callback down />))}
-
The child no longer owns the state:
it reads
showItemsfrom props and calls thesetShowIndexcallback on click — so the parent's singleshowIndexdecides which accordion is open. - How a child updates the parent: you can't reach into a parent's state directly. Instead the parent passes a function down; the child calls it, and the parent updates its own state. Data flows down, events flow up.
- Result: only one accordion open at a time — coordination that's impossible when each child manages itself.
Think of a row of light switches wired to one central dimmer. If each switch controlled only its own bulb (state in the child), no switch could turn the others off. Lifting state up moves the wiring to a single panel (the parent): press any switch and it tells the panel, which lights that bulb and dims the rest.
React DevTools#
The React DevTools browser extension adds two tabs to your console —Components and Profiler — that make the data layer visible.
-
Components tab:
shows the component tree (a view of the virtual DOM) on the left and the selected component's props and state
on the right — you can watch each accordion's
showItemsflip live. - Profiler tab: records interactions and shows which components re-rendered and how long each took — how you find slow components in a large app.
-
Note:
extra nodes like
RouterProviderin the tree come from libraries (React Router), not your code — don't panic when you see them.
Prop drilling & one-way data flow#
React data flows one way: parent → child → grandchild. To get data to a deeply nested component, you pass it through every component in between — even ones that don't use it. That's prop drilling.
has the data
doesn't need it — just passes it
actually wants it
- The problem: intermediate components become unwilling couriers, threading props they don't use. One or two levels is fine; ten levels deep is a mess.
- Don't abandon props: state and props are the heart of React — this isn't a reason to stop using them. It's a reason to avoid drilling the same data through many layers.
Context: a shared place for data#
Context is React's built-in fix for prop drilling: a shared space any component can read from, no matter how deeply nested — ideal for data needed in many places, like the logged-in user or the theme.
// utils/UserContext.js import { createContext } from "react"; const UserContext = createContext({ loggedInUser: "Default User" }); export default UserContext; // read it in a functional component with a hook const { loggedInUser } = useContext(UserContext); // read it in a class component with Consumer <UserContext.Consumer>{(data) => <h1>{data.loggedInUser}</h1>} </UserContext.Consumer>
-
createContextmakes the context with a default value. Two ways to consume it: theuseContexthook (functional components) orContext.Consumerwith a callback (class components, which can't use hooks). - What belongs in context: only data genuinely shared across many components — logged-in user, theme, etc. Don't dump everything into context; props are still right for local, parent-to-child data.
- It crosses lazy boundaries: a lazily-loaded page (e.g. About) still reads the current context value when it finally loads — the shared space persists.
Context.Provider: overriding & updating the value#
To replace the default value, wrap part of your tree in Context.Provider
and pass a value. Bind that value to state, and the whole subtree updates live when the state changes.
const[userName, setUserName] = useState(); // tie context to state - everything inside re-reads on change <UserContext.Provider value={{ loggedInUser: userName, setUserName }}><AppLayout /></UserContext.Provider>
- Provider sets the value: everything wrapped inside reads that value instead of the default. Wrap the whole app for global data, or just one branch (e.g. only the header) to scope it.
-
Bind to state for live updates:
put the state value and
its setter into
value. Now any component can read the user and callsetUserName— type in an input anywhere and every consumer updates instantly. - Providers nest: an inner provider overrides an outer one for its subtree. Header inside its own provider can show "Elon Musk" while the rest of the app shows "Akshay Saini" — the closest provider wins.
Context vs Redux (a preview)#
Both give you a central store readable from anywhere. The difference is what they are and when you reach for them.
Built into React
No install. Great for small and mid-size apps — and can scale to large ones with multiple contexts (user, theme, cart…).
External library
Installed separately. Adds structure for very large apps — middleware, thunks, devtools — at the cost of more setup.
- Don't reach for Redux too early: a small or medium app rarely needs it — context handles shared data well. Redux earns its keep when the app gets big and you want its extra tooling and conventions.
Gotchas & nice-to-knows#
HOC mutating the input
An HOC should wrap, not modify, the component passed in. Keep it pure and spread props through.
Child can't set parent state directly
Pass a setter function down as a prop; the child calls it. Data down, events up.
Drilling props many levels
Threading the same prop through 5–10 layers is a smell — use context for widely-shared data.
Everything in context
Context isn't a dumping ground. Local parent-to-child data should stay as props.
Provider scope is flexible
Wrap the whole app or just a branch; nested providers override outer values for their subtree.
Bracket keys for odd names
JSON keys like @type
aren't valid identifiers — read them with bracket notation: card["@type"].
Interview Q&A#
Q. What is a higher order component?
Q. What's the difference between a controlled and an uncontrolled component?
Q. What does "lifting state up" mean?
Q. How does a child component update its parent's state?
Q. What is prop drilling and why is it a problem?
Q. What is the Context API and when do you use it?
createContext
and read it with useContext
(or Context.Consumer
in class components).
Q. How do you provide and update a context value?
Context.Provider
and pass a value; everything inside reads that instead of the default. Bind the value to state and include the state setter in it — then any consumer can update the context, and the whole subtree re-renders live.
Q. Context vs Redux — what's the difference?
One-line recall#
Redux is a separate library — not part of React, and not mandatory. For large apps it gives you one central store. The whole flow is one cycle: dispatch an action → a reducer updates a slice → subscribed components re-render. #
When (and whether) to use Redux#
Redux is a standalone state-management library, popularly paired with React but not built into it — and not required. You built this whole app for 11 sessions without it.
- Not mandatory: small and mid-size apps don't need it — props and context handle them fine. Reach for Redux when the app is large, data is heavily shared, and many components read/write the same state.
- It's separate from React: a common interview confusion is thinking Redux is React. It isn't — you install it. Alternatives like Zustand exist too.
- Why it helps at scale: a predictable central store plus excellent debugging tools (Redux DevTools) make big apps easier to reason about.
Redux Toolkit (RTK), the modern way#
Old "vanilla" Redux was complex, needed many extra packages, and demanded lots of boilerplate. Redux Toolkit (RTK) is the standard way to write Redux today — less code, less setup.
The Redux part
Builds the store, slices, reducers. The odd @
in the name is just branding — it's a normal package.
The bridge
Connects Redux to React — gives you Provider, useSelector, useDispatch.
-
Two libraries do the job:
install both —
@reduxjs/toolkitfor Redux logic andreact-reduxto wire it into React. - Why RTK exists: it directly addresses old Redux's three pain points — too complicated, too many packages, too much boilerplate.
The architecture: store, slices & the cycle#
The store is one big object kept in a central place that any component can read or write. To stay organized, you split it into slices (logical partitions: a cart slice, a user slice, a theme slice…).
click fires an action
action calls a reducer fn
reducer updates the slice
subscribed component re-renders
- The one line to memorize: clicking Add dispatches an action, which calls a reducer function, which updates a slice of the store — and because the component is subscribed via a selector, it updates automatically.
- You can't touch the slice directly: Redux forbids modifying a slice outside this path. Every change goes dispatch → reducer.
- One big object is fine: keeping lots of data in the store is normal — slices just keep it tidy.
Building the store & wiring it in#
Create the store with configureStore
(from RTK), then make it available to React by wrapping the app in <Provider>
(from react-redux).
// utils/appStore.js import { configureStore } from "@reduxjs/toolkit"; import cartReducer from "./cartSlice"; const appStore = configureStore({ reducer: { // the app's ONE big reducer cart: cartReducer, // each slice plugs in here }, }); // App.js - provide the store to the whole app <Provider store={appStore}><AppLayout /></Provider>
-
Import from the right place:
configureStorecomes from RTK (a Redux job);Providercomes from react-redux (a React-bridge job). Knowing which is which is the whole point of two libraries. - Provider scope: wrap the whole app for global access, or just a branch if only part needs the store — same idea as a context provider.
Creating a slice#
A slice bundles a name, its initial state, and its reducers. createSlice
returns an object with auto-generated actions
and a reducer
— you export both.
import { createSlice } from "@reduxjs/toolkit"; const cartSlice = createSlice({ name: "cart", initialState: { items: [] }, reducers: { addItem: (state, action) => { state.items.push(action.payload); // mutate directly }, removeItem: (state) => { state.items.pop(); }, clearCart: (state) => { state.items.length = 0; }, }, }); export const { addItem, removeItem, clearCart } = cartSlice.actions; export default cartSlice.reducer;
-
Reducers map to actions:
each function under
reducers(e.g.addItem) auto-generates a matching action you dispatch. A reducer receives(state, action)and changes state based onaction.payload. -
Export both:
the named
actions(to dispatch) and the defaultreducer(to plug into the store). This syntax is what RTK expects — it's not arbitrary. -
reducervsreducers: the store takes one bigreducer(singular, a combination of slice reducers); a slice defines manyreducers(plural). A classic mix-up — watch the s.
Writing: useDispatch & payload#
To change the store, get the dispatch
function from useDispatch
and dispatch an action with your data. Whatever you pass becomes action.payload
in the reducer.
import { useDispatch } from "react-redux"; import { addItem } from "../utils/cartSlice"; const dispatch = useDispatch(); const handleAddItem = (item) => { dispatch(addItem(item)); // item becomes action.payload };
-
Payload plumbing:
Redux wraps your argument into an action object
{ type, payload }and hands it to the reducer as the second arg — soaction.payloadis whatever you dispatched. -
Pass a callback, don't call it:
use
onClick={() => handleAddItem(item)}, notonClick={handleAddItem(item)}— the second calls it immediately on render. (Plain JS, but a common bug.)
Reading: useSelector & subscribing#
To read from the store, use the useSelector
hook and select the exact slice portion you need. Doing so subscribes
the component — it re-renders automatically when that data changes.
import { useSelector } from "react-redux"; // GOOD - subscribe to the smallest slice you need const cartItems = useSelector((store) => store.cart.items); // BAD - subscribing to the whole store const store = useSelector((store) => store); const cartItems = store.cart.items;
-
Select the smallest portion:
it's called a selector
because you select a slice of the store. Subscribe to
store.cart.items, not the wholestore. - Why it's a performance trap: subscribing to the whole store re-renders this component on any change anywhere — even unrelated slices (a user login affecting a cart component). Narrow selectors avoid that.
- Auto-update: once subscribed, dispatching elsewhere updates the slice and this component re-renders on its own — no manual wiring.
Mutating state & Immer#
The biggest shift from old Redux: in RTK reducers you mutate
state directly (state.items.push(...)
). Old Redux forbade this and forced you to return a new copy.
// OLD vanilla Redux - never mutate, return a new state addItem: (state, action) => { const newState = { ...state }; newState.items.push(action.payload); return newState; } // RTK - just mutate; no return needed addItem: (state, action) => { state.items.push(action.payload); }
- Immer does the work: RTK uses the Immer library behind the scenes — it watches your "mutations," diffs old vs new, and produces a correct immutable copy. You write simple code; immutability still happens.
-
Two valid options:
either mutate
the existing state, or
returna brand-new state. RTK accepts both — but don't half-do it. -
Why
state = []fails: reassigning thestateparameter just repoints a local variable — it doesn't mutate the real state Immer is tracking. Usestate.items.length = 0(mutate) orreturna new object instead.
Immer is like editing a tracked Google Doc. You just type your changes into the working draft (mutate freely), and the system quietly records a clean version history — you never manually photocopy the document first. Reassigning state = []
is like writing your edits on a sticky note instead of the doc: the note (local variable) changes, the real document doesn't.
Redux DevTools#
A browser extension that activates on any Redux app — a major reason to use Redux at scale, since it makes a busy store debuggable.
- Action log & diffs: every dispatched action appears with the new state and a git-style diff of exactly what changed.
- Time travel: jump to any past state, replay the user's actions, or skip an action — you can reproduce the exact moment a bug appeared.
- Trace & tests: it shows where each action was dispatched and even sketches a test case for the reducer.
Gotchas & nice-to-knows#
Selecting the whole store
useSelector(s => s)
re-renders on every change anywhere. Select the smallest slice you need.
reducer vs reducers
Store config uses reducer
(one big); a slice uses reducers
(many). Easy typo, real bug.
Reassigning state
state = []
won't clear the cart — it repoints a local var. Mutate (state.items.length = 0
) or return a new state.
Calling vs passing the handler
onClick={fn(item)}
runs on render. Use onClick={() => fn(item)}.
console.log a reducer's state
It logs an Immer proxy. Wrap it: console.log(current(state))
(current
from RTK).
RTK Query for fetching
RTK includes RTK Query for data fetching — the modern replacement for old middleware/thunks.
Interview Q&A#
Q. Is Redux part of React, and is it mandatory?
Q. What is Redux Toolkit and why use it over vanilla Redux?
configureStore
and createSlice
out of the box.
Q. Walk through the Redux data flow.
useSelector; writes use useDispatch.
Q. What's a slice?
createSlice
(giving it a name, initial state, and reducers), and plug its reducer into the store's reducer object.
Q. How do you read and write the store from a component?
useSelector
hook, selecting the specific slice you need (which subscribes the component). Write with useDispatch
to get dispatch, then dispatch an action — whatever you pass becomes action.payload
in the reducer. Both hooks come from react-redux.
Q. Why select the smallest portion of the store?
store.cart.items
) keeps it efficient.
Q. Can you mutate state in a reducer? What about old Redux?
state.items.push(...)
), and RTK uses Immer behind the scenes to produce a correct immutable copy. Vanilla Redux was the opposite: never mutate, always return a new state. You can also return a new state in RTK if you prefer.
Q. What's the difference between context and Redux?
One-line recall#
Tests are how you stop new code from quietly breaking old code. Every React test is the same three beats: render a component → query the rendered output → assert something about it. The tools change; that rhythm doesn't. #
Why test, and the three kinds#
Any line of code can introduce a bug — including a bug in a feature you weren't even touching. Automated tests catch those regressions before users do, which is why testing is part of writing the code, not a chore bolted on after.
One piece, in isolation
Test a single component (or pure function) on its own — does it render, does it return the right value? The bulk of what you write.
Pieces working together
Test a flow that spans multiple components — type in a box, click search, see the list change. Several components must cooperate.
The whole journey
Drive the real app like a user, start to finish. Done with tools like Cypress, Puppeteer, or Selenium — usually owned by QA, not the dev.
- Where developers live: you mostly write unit and integration tests. E2E is a separate discipline with its own tooling.
- The payoff is regression safety: a passing suite means a change in one corner didn't silently break another. That confidence is the whole point.
The toolchain: RTL, Jest, jsdom, Babel#
React testing leans on a small stack. Each piece has one job; templates like Create React App wire it up for you, but doing it by hand once shows what each part is actually for.
The query/assert API
Built on top of DOM Testing Library, tuned for React. Gives you render, screen, fireEvent. It's a library, not a runner.
The test runner
Runs the tests and provides test/it, describe, and expect. RTL works with it. Runs your code through Babel.
A fake browser
A browser-like environment in Node so tests can "render" DOM with no real browser. For Jest 28+, install jest-environment-jsdom
separately.
Translates your code
Babel transpiles modern JS/JSX so Jest can read it. @babel/preset-react
(runtime automatic
) turns JSX into elements.
// scaffolding the setup (Parcel project, by hand) npm i -D @testing-library/react jest babel-jest @babel/core npx jest --init // no TS, env: jsdom, coverage: yes, clearMocks: yes npm i -D jest-environment-jsdom // Jest 28+ needs this separately // package.json "scripts" : { "test" : "jest", "watch-test" : "jest --watch" // re-runs on save, like HMR }
-
Two JSX errors you'll hit:
"JSX isn't enabled" → add
@babel/preset-reactwith{ runtime: "automatic" }. "toBeInTheDocument is not a function" → import@testing-library/jest-dom, which adds the DOM matchers. -
Parcel vs Babel clash:
Parcel runs its own transpilation; add a
.parcelrc(or scope the Babel config) so the two don't fight over your files. -
Coverage folder:
Jest writes a
/coveragedirectory — add it to.gitignoreso it doesn't pollute your history.
The first test: a pure function#
Before any React, the shape of a test is clearest on a plain function. A test is test("description", callback); inside, you expect(value).toBe(expected). That's the entire skeleton.
// sum.js export const sum = (a, b) => a + b; // sum.test.js import { sum } from "./sum"; test("adds two numbers", () => { const result = sum(3, 4); expect(result).toBe(7); // assertion });
-
expectis the assertion: it wraps a value, then a matcher (.toBe,.toEqual,.toBeTruthy…) states what should be true..toBeusesObject.isunder the hood. -
File conventions:
Jest auto-finds files in a
__tests__folder (the double underscore is "dunder") or any file ending.test.js/.spec.js(and the.tsvariants).
Unit testing a component: render, query, assert#
To test a UI component you first render
it into jsdom, then reach into what was rendered through the screen
object, then assert. screen
exposes the queries; the most useful is getByRole.
import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; // gives toBeInTheDocument import Contact from "../Contact"; test("should load Contact component", () => { render(<Contact />); // 1. render to jsdom const heading = screen.getByRole("heading"); // 2. query expect(heading).toBeInTheDocument(); // 3. assert });
getByRole
Find elements by their accessibility role —heading, button, textbox
(the role for inputs). The recommended, robust query.
getByText, getByPlaceholderText, getByTestId
By visible text, by placeholder, by an explicit data-testid. getByTestId
always works when nothing else fits — it's the escape hatch.
-
Roles aren't tag names:
an
<input>'s role istextbox, notinput. A wrong role won't crash silently — Jest lists the roles it actually found, which is how you discover the right one. -
Narrow an ambiguous query:
pass an options object —
getByRole("button", { name: "Login" })— when several elements share a role. -
What a query returns:
not a raw DOM node but a React element / fiber node — JSX is ultimately an object. Logging one shows an
HTMLInputElement-like object with its props. - Failures are generous: when an assertion fails, Jest prints the entire rendered HTML, so you can see exactly what was there versus what you queried for.
getBy vs getAllBy, and matchers#
Use getBy*
when you expect exactly one match — more than one is an error. Use getAllBy*
when you expect several; it returns an array you can count.
// expecting many - returns an array const inputs = screen.getAllByRole("textbox"); expect(inputs.length).toBe(2); expect(inputs.length).not.toBe(3); // .not inverts any matcher // text queries accept a regex - looser matching screen.getByText(/cart/i); // matches "Cart - 0 items"
-
Singular query, plural match = crash:
getByRole("textbox")with two text boxes throws. Switch togetAllByRole— the error message tells you to. -
Matchers are rich:
toBe,toBeTruthy/Falsy,toBeGreaterThan,toBeNull,toBeNaN, and the.notinverter. Type a.afterexpect(x)and the editor lists them. -
Regex over exact strings:
getByText("Cart - 0 items")is brittle;getByText(/cart/i)survives small copy changes.
Components that need context: providers in tests#
jsdom understands JSX, but it doesn't know about Redux or React Router. Render a component that uses useSelector
or <Link>
on its own and it crashes — you must wrap it in the same providers the real app gives it.
import { render, screen, fireEvent } from "@testing-library/react"; import { Provider } from "react-redux"; import { BrowserRouter } from "react-router-dom"; import appStore from "../utils/appStore"; import Header from "../Header"; test("Header has a login button", () => { render( <BrowserRouter><Provider store={appStore}><Header /></Provider></BrowserRouter>); const btn = screen.getByRole("button", { name: "Login" }); expect(btn).toBeInTheDocument(); });
-
Read the failure to find the missing provider:
"could not find react-redux context" → wrap in
<Provider>. "error in Link component" → wrap in<BrowserRouter>. Each missing context fails with its own clear message. -
Same store as the app:
import the real
appStoreso selectors resolve against actual initial state.
Testing a component is like auditioning an actor on a bare stage. jsdom is the empty stage — it has the floor and the lights (JSX, the DOM), but not the props the actor reaches for. If the role calls for them to pick up a phone (useSelector
) or walk through a door (<Link>
), you have to set those props on stage first (<Provider>, <BrowserRouter>
) — otherwise they grab at thin air and the scene falls apart.
Firing events: clicks and typing#
To simulate a user, import fireEvent
from RTL. fireEvent.click
triggers a click; fireEvent.change
simulates typing — and you hand it the event object the browser would normally build.
// click toggles Login -> Logout fireEvent.click(loginButton); expect(screen.getByRole("button", { name: "Logout" })) .toBeInTheDocument(); // typing = a change event with a faked e.target.value fireEvent.change(searchInput, { target: { value: "burger" }, });
-
You fake the event object:
in a real browser
e.target.valuecomes from the DOM; in jsdom you supply{ target: { value: "burger" } }yourself to mimic what anonChangehandler receives. - Assert the consequence, not the click: after firing, query for the new state (the "Logout" button, the filtered list) — that's what proves the behavior works.
Props & mock data#
A component that takes props (like a restaurant card) needs data supplied in the test. You don't hit the network — you create mock data: a JSON snapshot of a real response, kept in a /mocks
folder and imported.
import MOCK_DATA from "../mocks/resCardMock.json"; test("renders card from props", () => { render(<RestaurantCard resData={MOCK_DATA} />); const name = screen.getByText("Leon's Burgers &Wings"); expect(name).toBeInTheDocument(); });
- Where mock data comes from: log the real prop while the app runs, copy the object, and paste it into a JSON file. Now the test is deterministic and offline.
-
Pass it as the prop:
the mock fills the same prop the parent normally passes (
resData), so the component renders exactly as it would live.
Integration testing: mocking fetch & a full flow#
An integration test renders a whole feature — e.g. the Body with its search box, cards, and API call. Because jsdom has no fetch
(it's a browser superpower, not core JS), you replace global.fetch
with a mock that resolves your mock data in the same promise shape.
import MOCK_RESLIST from "../mocks/resListMock.json"; import { act } from "react-dom/test-utils"; // fetch returns a promise -> json() returns a promise -> data global.fetch = jest.fn(() => { return Promise.resolve({ json: () => Promise.resolve(MOCK_RESLIST), }); }); test("search filters the list to 4 cards", async() => { await act(async() => render(<BrowserRouter><Body /></BrowserRouter>) ); const input = screen.getByTestId("searchInput"); fireEvent.change(input, { target: { value: "burger" } }); fireEvent.click(screen.getByRole("button", { name: "Search" })); const cards = screen.getAllByTestId("resCard"); expect(cards.length).toBe(4); });
-
Match fetch's exact shape:
real
fetchresolves to a response whose.json()also returns a promise. Your mock must nestPromise.resolve({ json: () =>Promise.resolve(data) })or the component'sawait res.json()breaks. -
Wrap async renders in
act: when rendering triggers state updates (a fetch thensetState), Jest warns unless youawait act(async () =>render(...)).actcomes fromreact-dom/test-utils. -
data-testidfor counting: tag each card withdata-testid="resCard"in the component, thengetAllByTestIdcounts them — assert 20 before search, 4 after. Snapshotting before-and-after proves the filter actually ran. - Tests run offline: because fetch is faked, the suite needs no network and no browser — it works with Wi-Fi off.
Organizing tests & lifecycle hooks#
describe blocks
Wrap related tests in describe("name", () =>{...})
to group them. Purely organizational — can be nested, changes nothing about how tests run.
it === test
it
is just an alias of test. Many prefer it("should ...")
because it reads as a sentence. Pick one and stay consistent.
beforeAll / beforeEach
beforeAll
runs once before the whole file; beforeEach
runs before every test. Use for shared setup.
afterEach / afterAll
afterEach
runs after each test, afterAll
once at the end. Use for cleanup. Order: beforeAll →(beforeEach → test → afterEach)…→afterAll.
-
Hover for docs:
in the editor, hovering a matcher like
toBeTruthyshows what it does (e.g. it fails on JavaScript's six falsy values). Cheap way to learn the API as you write.
Interview Q&A#
Q. What are the three types of testing?
Q. What's the difference between Jest and React Testing Library?
test, describe, and expect. React Testing Library is a library built on DOM Testing Library that renders components and queries the output (render, screen, fireEvent
). They're used together.
Q. What three steps does a React test follow?
screen, then assert something with expect. Render, query, assert — every test repeats that rhythm.
Q. What is jsdom and why do tests need it?
fetch, which is why those have to be mocked.
Q. Why prefer getByRole, and what's getByTestId for?
getByRole
finds elements by their accessibility role and is the most robust query. getByText
is a fallback. getByTestId
is the escape hatch — you add a data-testid
attribute and query it when nothing else works, useful for counting elements like cards.
Q. When do you use getAllBy instead of getBy?
getBy*
when exactly one element should match — multiple matches throw an error. Use getAllBy*
when several match; it returns an array, so you can assert on its length (e.g. two input boxes, four cards).
Q. A component uses Redux and Link — why does the test crash, and how do you fix it?
useSelector
and <Link>
fail with "no context" errors. Wrap the rendered component in the same providers the app uses —<Provider store={appStore }>
for Redux and <BrowserRouter>
for routing.
Q. Why mock fetch, and what shape must the mock have?
global.fetch
with a Jest mock that returns a promise resolving to an object whose json()
also returns a promise resolving to your mock data, matching real fetch exactly.
Q. How do you simulate a user typing and clicking?
fireEvent
from RTL. fireEvent.click(element)
simulates a click. fireEvent.change(input, { target: { value: "burger" } })
simulates typing — you supply the event object's target.value
that the browser would normally provide.
Q. What's the difference between test and it, and describe?
it
is just an alias for test
— identical behavior, often chosen because it("should ...")
reads as a sentence. describe
groups related tests into a block for organization; it can be nested and doesn't change how tests run.