React NotesRohit’s interview study guide
Chapter 02

Components & Props

Function components, props and children, composition over inheritance, default values and PropTypes old and new, fragments, conditional rendering, lists and keys, and how to design components other people can reuse.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Function components

A component is a JavaScript function that takes one argument — an object of props — and returns what to render: JSX, a string, a number, null, or an array of those. Components are how you split a UI into pieces you can name, reuse and reason about one at a time. <Greeting name="Rohit" /> reads like an HTML tag, but it means "React, call Greeting with { name: "Rohit" } and put whatever it returns here".

The mental model: a component is a recipe, and React is the cook. You write the recipe once; React decides when to follow it (on first render and whenever its inputs change) and what to do with the dish. You never call the recipe yourself, and a recipe never reaches into the kitchen to change things — given the same ingredients it produces the same dish. That last part is React's purity rule: rendering must be pure. Side effects (requests, timers, DOM changes) belong in event handlers and effects, not in the function body.

Greeting.jsx
function Avatar({ name, size }) {
  return (
    <img
      src={`/avatars/${name}.png`}
      alt={name}
      width={size}
      height={size}
    />
  );
}

export function Greeting({ name }) {
  return (
    <div className="greeting">
      <Avatar name={name} size={40} />
      <p>Hello, {name}!</p>
    </div>
  );
}

export function App() {
  return (
    <>
      <Greeting name="Rohit" />
      <Greeting name="Asha" />
    </>
  );
}

// <App /> renders:
// <div class="greeting"><img alt="Rohit" width="40" height="40" src="/avatars/Rohit.png"><p>Hello, Rohit!</p></div>
// <div class="greeting"><img alt="Asha" width="40" height="40" src="/avatars/Asha.png"><p>Hello, Asha!</p></div>
What’s happening
  1. React calls App() with an empty props object. It returns two Greeting elements — descriptions, not calls. Nothing inside Greeting has run yet.
  2. React then calls Greeting({ name: "Rohit" }). The parameter is destructured, so name is "Rohit". It returns a div containing an Avatar element and a p.
  3. React calls Avatar({ name: "Rohit", size: 40 }), which returns an img element. Built-in elements like img are the leaves: React turns them into DOM nodes.
  4. The same happens for "Asha". The two Greetings are separate instances: if Greeting had state, each would have its own copy.
  5. The result is a tree: App → two Greetings → each an Avatar and a p. Components compose like functions do, and each one only knows about its own props.

The rules every function component follows:

  • Name starts with a capital letter. JSX compiles <Greeting /> to a reference to the Greeting variable but <greeting /> to the string "greeting". A lowercase component renders an unknown HTML element instead, and React warns: "The tag <greeting> is unrecognized in this browser. If you meant to render a React component, start its name with an uppercase letter."
  • Returns one thing: one element (use a fragment for siblings), null for nothing, a string or an array.
  • Is pure during render: no fetches, no document.title = …, no changing variables outside the function. Compute from props and state; do side effects in handlers or effects.
  • Is defined at the top level of a module, never inside another component (gotcha below).
AdvancedDon't call components as functions

{Counter()} instead of <Counter /> "works" until it doesn't. Calling it directly runs it as part of the parent's render, so its useState becomes one of the parent's hooks. With a condition, {show && Counter()}, hiding it changes how many hooks the parent calls, and React 19.3 throws: "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." With <Counter />, React creates a separate instance with its own hooks, and showing or hiding it is just a mount or unmount (see Rules of hooks).

#One component per file: default vs named exports

Added

The common convention is one exported component per file, with the file named after it: UserCard.jsx exports UserCard. Small helpers that only that component uses can live in the same file without being exported. You find a component by its file name, a change to one component touches one file in the git history, and Vite's React plugin can only Fast Refresh a file (keep state while you edit) when everything it exports is a component.

The second decision is how to export it. A default export is the module's one unnamed main value; the importer picks any name it likes. A named export has a fixed name that every importer must spell exactly. Mental model: a default export is a parcel left at the door: whoever picks it up can write any label on it. A named export is a parcel with the recipient's name printed on it: get the name wrong and it isn't delivered.

Button.jsx
// default export: one per module, imported under any name
export default function Button({ children, ...props }) {
  return <button {...props}>{children}</button>;
}
charts.jsx
// named exports: as many as you like, imported by exact name
export function BarChart() {
  return <p>bar chart</p>;
}

export function LineChart() {
  return <p>line chart</p>;
}
usage.jsx
import Button from "./Button.jsx";      // any name works...
import Whatever from "./Button.jsx";    // ...this is the same function (Whatever === Button is true)
import { BarChart, LineChart } from "./charts.jsx";
import { BarChart as Chart } from "./charts.jsx"; // renaming a named import is explicit
What’s happening
  1. export default function Button makes Button the module's default export. The name Button inside the file is only the function's own name; importers don't have to use it.
  2. import Whatever from "./Button.jsx" binds the default export to a new local name. The test confirmed Whatever === Button is true and Whatever.name is still "Button".
  3. charts.jsx has two named exports. import { BarChart } must match the exported name exactly; as Chart renames it locally, and that rename is visible in the import line.
  4. The difference shows up when someone gets it wrong, as the next example shows.
names.mjs
// format.mjs
export function formatPrice(n) { return `₹${n}`; }
export default function formatDate(d) { return d.toISOString().slice(0, 10); }

// rename.mjs: wrong name for the default export, no error
import formatPrice from "./format.mjs";
console.log(formatPrice(new Date("2026-10-09"))); // 2026-10-09  (it's formatDate!)

// typo.mjs: wrong name for a named export
import { formatPrise } from "./format.mjs";
// Node 26: SyntaxError: The requested module './format.mjs' does not provide an export named 'formatPrise'
// vite build: [MISSING_EXPORT] "formatPrise" is not exported by "format.mjs".
What’s happening
  1. rename.mjs imports the default export and calls it formatPrice. Nothing complains, because a default import is just "give me default and call it this". The call actually runs formatDate and prints 2026-10-09: the name in the importing file is a lie.
  2. typo.mjs asks for a named export that doesn't exist. Node refuses to even start the module and throws SyntaxError: The requested module './format.mjs' does not provide an export named 'formatPrise', before any code runs.
  3. Vite's production build catches the same mistake at build time: [MISSING_EXPORT] "formatPrise" is not exported by "format.mjs". A misspelt name never reaches users.
  4. This is the core argument for named exports: the name is part of the contract, so tools can check it, rename it and find every use of it.

How the two compare in practice:

Default exportNamed export
Name at importImporter chooses; can drift (Button, Btn, MyButton)Fixed; typos are build errors
Rename refactoringRenaming the function doesn't rename the imports, and grep misses differently named importsIDE "Rename symbol" updates every import
IDE auto-importWorks by guessing from the file nameReliable, because the name is known
Tree shakingFine for a single function or componentFine; unused exports are dropped
Several per fileOne onlyAs many as you like
React.lazyWorks directlyNeeds a small mapping (below)

Tree shaking works with both; the one trap is a default export of an object (export default { formatPrice, formatDate }), which bundlers can't split apart (see the Advanced part). Barrel files (index.js files that re-export a folder, export { default as Button } from "./Button.jsx") give short import paths, but every import from the barrel loads every module it re-exports during development and in tests, and a re-exported module with side effects can stop the bundler dropping it. Keep barrels small, one per feature or package, not one for the whole app.

Many teams use named exports everywhere, and default exports only where a tool requires one: Next.js page.tsx and layout.tsx files, Storybook's story metadata, config files like vite.config.ts, and the target of React.lazy.

React.lazy expects the dynamic import() to resolve to a module with a default export (see Code splitting with React.lazy and Suspense). For a named export, map it:

LazyChart.jsx
import { lazy, Suspense } from "react";

// charts.jsx only has named exports: turn the one we want into { default: … }
const LineChart = lazy(() => import("./charts.jsx").then((m) => ({ default: m.LineChart })));

export function Dashboard() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <LineChart />
    </Suspense>
  );
}

// renders "Loading…", then "line chart"
// without the .then: "Element type is invalid. Received a promise that resolves to: undefined.
//                     Lazy element type must resolve to a class or function."
What’s happening
  1. import("./charts.jsx") resolves to the module namespace object { BarChart, LineChart }. It has no default property.
  2. .then((m) => ({ default: m.LineChart })) builds a new object whose default is the component, which is the shape lazy reads. The test saw Loading… first and then line chart.
  3. Without the mapping, lazy reads module.default, gets undefined, and React throws the "Element type is invalid … resolves to: undefined" error, caught in the test by an error boundary.
  4. The chunk is still split at the import(): the mapping runs after the download, so it costs nothing.

Colocate a component's other files next to it, in a folder named after it, so moving or deleting the component moves or deletes everything that belongs to it (see Structuring a React project):

text
src/components/Button/
  Button.jsx           the component (export function Button)
  Button.module.css    its styles
  Button.test.jsx      its tests
  Button.stories.jsx   its Storybook stories
  index.js             export { Button } from "./Button.jsx";  (optional, for short imports)
AdvancedTree shaking: named exports vs a default-exported object

Two utility modules, each with two functions, and an entry that uses only one of them. Built with Vite 8 (Rolldown) in library mode:

utils.js
// utils-object.js: one default export holding both functions
function formatPrice(n) { return "PRICE:" + n; }
function formatDate(d) { return "DATE:" + d; }
export default { formatPrice, formatDate };

// utils-named.js: two named exports
export function formatPrice(n) { return "PRICE:" + n; }
export function formatDate(d) { return "DATE:" + d; }

// entry-object.js: import utils from "./utils-object.js";       utils.formatPrice(5)
// entry-named.js:  import { formatPrice } from "./utils-named.js";  formatPrice(5)

// built output contains "DATE:"?  object: true   named: false   (also true for object with minify on)
What’s happening
  1. With named exports, the bundler sees exactly which binding the entry imports. formatDate is never referenced, so it's dropped: the built file contains only formatPrice and the console.log.
  2. With the object, the entry imports the whole object and reads .formatPrice from it at runtime. The bundler can't prove formatDate is unused, so the output keeps both functions and the object literal: console.log({ formatPrice, formatDate }.formatPrice(5)).
  3. Turning minification on didn't change that: the minified build still contained "DATE:".
  4. So it isn't "default exports break tree shaking"; it's "an object is one value". export default function Button is a single function and is dropped just as well as a named export when unused.

#Props

Props are the inputs to a component: the attributes you write on the JSX tag, collected into one object and passed as the function's argument. They're how a parent configures a child — what text to show, what data to display, what to do when something is clicked. Any JavaScript value can be a prop: strings, numbers, objects, arrays, functions, and even JSX.

From my notes: props are inputs passed from a parent, they're immutable (the child can't change them), and they're what make a component dynamic. The mental model is function arguments: a component can't reach out and change the arguments it was called with, and when the parent wants a different result, it calls again with different arguments — React re-renders the child with the new props.

UserCard.jsx
export function UserCard({ user, compact, onSelect, badge }) {
  const { name, role, skills } = user;

  return (
    <li className={compact ? "card compact" : "card"} onClick={() => onSelect(user.id)}>
      <strong>{name}</strong>
      {badge}
      {!compact && <span> · {role}</span>}
      {!compact && <small> · {skills.join(", ")}</small>}
    </li>
  );
}

export function Team({ members, onSelect }) {
  return (
    <ul>
      {members.map((member) => (
        <UserCard
          key={member.id}
          user={member}
          compact={members.length > 3}
          onSelect={onSelect}
          badge={member.lead ? <em> lead</em> : null}
        />
      ))}
    </ul>
  );
}

// <Team members={[{ id: 7, name: "Rohit", role: "Engineer", skills: ["React", "Kotlin"], lead: true },
//                 { id: 9, name: "Asha", role: "Designer", skills: ["Figma"] }]} onSelect={fn} />
// → <li class="card"><strong>Rohit</strong><em> lead</em><span> · Engineer</span><small> · React, Kotlin</small></li>
//   <li class="card"><strong>Asha</strong><span> · Designer</span><small> · Figma</small></li>
// clicking "Asha" calls fn(9)
What’s happening
  1. Team maps over members and creates a UserCard for each one, passing four different kinds of prop: an object (user), a boolean expression (compact, false here because there are only 2 members), a function (onSelect), and JSX or null (badge).
  2. UserCard receives them all as one object and destructures it in the parameter list. Then it destructures user again into name, role and skills — ordinary JavaScript.
  3. badge is a piece of UI chosen by the parent: <em> lead</em> for Rohit, null for Asha. {badge} renders it, or nothing for null. Passing JSX as a prop is how a parent customises part of a child without the child knowing what goes there.
  4. onSelect is the parent's function. The card calls it with its own id, so clicking Asha runs onSelect(9) — the data stays owned by the parent; the child just reports what happened.
  5. key={member.id} is written like a prop but is not one — React uses it to track list items and doesn't pass it on (see the gotcha below).

How props are written:

  • Strings can use quotes: role="admin". Everything else needs braces: size={48}, active={false}, user={user}, onClick={handleClick}. size="48" gives the string "48"; size={48} the number.
  • A bare attribute means true: <Button disabled /> is disabled={true}.
  • Spread passes every property of an object as a prop: <UserCard {...member} />. Rest collects the props you didn't name, which is how wrappers forward attributes to the element underneath:
React
function TextField({ label, ...inputProps }) {
  return (
    <label>
      {label}
      <input {...inputProps} />
    </label>
  );
}

// <TextField label="Email" type="email" placeholder="you@example.com" required />
// → <label>Email<input placeholder="you@example.com" required="" type="email"></label>
What’s happening
  1. { label, ...inputProps } takes label out and puts the remaining props (type, placeholder, required) into a new object inputProps.
  2. <input {...inputProps} /> spreads that object back out as attributes, so the input gets all three without TextField listing them.
  3. The component stays small but supports every <input> attribute — name, autoComplete, onChange, anything — which is the pattern behind most design-system components (see Designing reusable components).

Props are read-only: in development React freezes the props object, and assigning to it throws TypeError: Cannot assign to read only property 'text' of object '#<Object>' (see One-way data flow). If a component needs a value that changes over time, that's state, which State vs props compares side by side.

#children and composition over inheritance

children is a special prop holding whatever you put between a component's opening and closing tags. <Card title="Profile"><p>Rohit</p></Card> calls Card with { title: "Profile", children: <p>Rohit</p> }. It lets a component be a container that doesn't know or care what it contains — like a picture frame, which works the same whatever picture you put in it.

That's the heart of what React calls composition over inheritance. In class-based UI frameworks you specialise a component by subclassing it (class DangerButton extends Button). React doesn't do that: you build specific components by combining generic ones and passing them content and configuration through props and children. From my notes: composition "means it's flexible and generic — it doesn't care what is being passed to it, either through props or children."

My notes had two examples; the first (the children version) was a screenshot that didn't come through, so this is a new one:

Card.jsx
export function Card({ title, children }) {
  return (
    <section className="card">
      <h2>{title}</h2>
      <div className="card-body">{children}</div>
    </section>
  );
}

export function Dashboard() {
  return (
    <>
      <Card title="Profile">
        <p>Rohit Varma</p>
        <button>Edit</button>
      </Card>
      <Card title="Stats">
        <ul>
          <li>Commits: 42</li>
        </ul>
      </Card>
    </>
  );
}

// → <section class="card"><h2>Profile</h2><div class="card-body"><p>Rohit Varma</p><button>Edit</button></div></section>
//   <section class="card"><h2>Stats</h2><div class="card-body"><ul><li>Commits: 42</li></ul></div></section>
What’s happening
  1. In the first <Card>, the two elements between the tags (<p> and <button>) become props.children — an array of two elements, because there are two. In the second card, children is a single <ul> element.
  2. Card puts {children} inside card-body without inspecting it. Rendering an array or a single element looks the same from Card's point of view.
  3. Card owns the frame (the section, the heading, the CSS class); Dashboard owns the content. Neither has to change when the other does.
  4. The children are created by Dashboard, so they can use Dashboard's state and handlers directly — the Edit button's onClick could call a function in Dashboard without Card knowing it exists. That's how composition removes a lot of prop drilling.

When a component has several places to fill, pass each one as its own prop. This is the second example from my notes (written in TypeScript there with React.FC<ModalProps>; here as JSX):

Modal.jsx
export function Modal({ header, body, footer }) {
  return (
    <div className="modal" role="dialog">
      <div className="modal-header">{header}</div>
      <div className="modal-body">{body}</div>
      <div className="modal-footer">{footer}</div>
    </div>
  );
}

export function App() {
  return (
    <Modal
      header={<h2>Modal Title</h2>}
      body={<p>This is the modal content</p>}
      footer={<button>Close</button>}
    />
  );
}
What’s happening
  1. header, body and footer are ordinary props whose values happen to be JSX elements. In TypeScript each is typed React.ReactNode — "anything React can render".
  2. Modal places each one in its own wrapper. The output is <div class="modal" role="dialog"><div class="modal-header"><h2>Modal Title</h2></div><div class="modal-body"><p>This is the modal content</p></div><div class="modal-footer"><button>Close</button></div></div>.
  3. These are often called slots (the same idea as Angular's <ng-content select> or Vue's named slots). children is just the default slot.
  4. The modal works for a confirm dialog, a form or an image preview without changing — that's the "doesn't care what is being passed" part of my notes.

What children can be — the test logs typeof for each case:

You writeprops.children is
<Spy />undefined
<Spy>text</Spy>the string "text"
<Spy><b /></Spy>one element (an object)
<Spy><b /><i /></Spy>an array of 2 elements
<Spy>{() => 1}</Spy>a function (the "render prop as children" pattern, see Render props)
AdvancedSpecialisation by composition, and the legacy Children helpers

Where inheritance would make DeleteDialog extends Dialog, composition makes DeleteDialog render a Dialog with specific props:

Dialog.jsx
function Dialog({ title, tone = "neutral", children }) {
  return (
    <div role="alertdialog" className={`dialog dialog-${tone}`}>
      <h2>{title}</h2>
      {children}
    </div>
  );
}

export function DeleteDialog({ itemName, onConfirm }) {
  return (
    <Dialog title={`Delete ${itemName}?`} tone="danger">
      <p>This can't be undone.</p>
      <button onClick={onConfirm}>Delete</button>
    </Dialog>
  );
}
What’s happening
  1. DeleteDialog is the "subclass": it fixes the tone to danger, builds the title from its own prop (Delete report.pdf?), and supplies the body.
  2. Dialog doesn't know DeleteDialog exists. You can write RenameDialog or ShareDialog the same way, and fixing a bug in Dialog fixes all of them.
  3. There's no super, no overriding, no fragile base class: each component's contract is just its props. The test checks the heading, the dialog-danger class, and that clicking Delete calls onConfirm once.

React also has Children.map, Children.count, Children.toArray and cloneElement for inspecting or changing children. The React docs list them under legacy APIs, because they see only the elements you wrote directly:

React
<NumberedList><span>Plan</span><span>Build</span>{false}<span>Ship</span></NumberedList>
// Children.map gives: <li>1. Plan</li><li>2. Build</li><li>3. </li><li>4. Ship</li>

<NumberedList><Steps /></NumberedList>   // Steps returns <><span>Plan</span><span>Build</span></>
// Children.map gives: <li>1. <span>Plan</span><span>Build</span></li>
What’s happening
  1. Children.map counts false, null and undefined as children, so the conditional {false} produced an empty item 3..
  2. When the items come from another component (<Steps />), Children.map sees one child — the Steps element — not the two spans it renders. Wrapping content in a component silently breaks the parent's logic.
  3. That's why newer code passes data instead (<NumberedList items={[…]} />) or uses context-based Compound components.

#Default values: default parameters vs defaultProps

Optional props need fallback values: a button's size should be "md" unless someone says otherwise. Today you write them as JavaScript default parameters in the destructuring pattern. Older code set a static defaultProps object on the component instead. In React 19 that old way no longer works for function components — it's silently ignored — so it's worth recognising.

Heading.jsx
function Heading({ text = "Hello, world!", level = 2 }) {
  const Tag = `h${level}`;
  return <Tag>{text}</Tag>;
}

<Heading />                    // <h2>Hello, world!</h2>
<Heading text="Hi" level={1} /> // <h1>Hi</h1>
<Heading text={undefined} />   // <h2>Hello, world!</h2>
<Heading text={null} />        // <h2></h2>   ← null is a value, so no default
What’s happening
  1. { text = "Hello, world!", level = 2 } destructures props and supplies a fallback for each missing property. <Heading /> gets both defaults and renders an h2.
  2. const Tag = `h${level}` builds the string "h1" or "h2". A capitalised variable holding a string can be used as a JSX tag, so <Tag> renders whichever heading level was asked for.
  3. Passing text={undefined} is the same as not passing it — JavaScript defaults apply when the value is undefined.
  4. Passing text={null} does not trigger the default: null is a real value, so text is null and the heading is empty. If null can come from an API, use text ?? "Hello, world!" in the body instead.

Old and new:

React
// React 18 and earlier — function components
function OldHeading({ text }) {
  return <h2>{text}</h2>;
}
OldHeading.defaultProps = { text: "Hello, world!" };

// React 19.3: <OldHeading /> renders <h2></h2> — the default is silently ignored, no warning

// Class components still support defaultProps in React 19
class ClassHeading extends Component {
  static defaultProps = { text: "Hello from a class" };
  render() {
    return <h2>{this.props.text}</h2>;
  }
}
// <ClassHeading /> → <h2>Hello from a class</h2>
What’s happening
  1. Before default parameters were common (and before hooks), defaultProps was the standard way: React merged that object into the props before calling the component.
  2. React 18.3 added a warning for defaultProps on function components, and React 19 removed support. I ran it on 19.3: <OldHeading /> renders an empty <h2></h2> and logs nothing — no error, no warning — so an un-migrated component just loses its defaults.
  3. Class components keep defaultProps in React 19, because a class has no parameter list to put defaults in. <ClassHeading /> still renders Hello from a class.
  4. Migrating is mechanical: move each defaultProps entry into the destructuring pattern ({ text = "Hello, world!" }). React's upgrade guide offers codemods for this and for PropTypes.

#PropTypes: runtime prop checking

PropTypes describe what props a component expects — "name is a required string, age is a number" — and, in older React, React checked them at runtime in development and logged a warning when a prop had the wrong type. They come from the separate prop-types package. From my notes: "If you do TypeScript you don't need these, but for projects still on JavaScript, it's good to have."

That was right in 2024; in React 19 it changed. PropTypes were deprecated back in React 15.5 (2017), and React 19 removed the checking entirely: React no longer reads propTypes at all. They're silently ignored. So today "PropTypes or TypeScript?" is really "TypeScript (or JSDoc types), or no checking".

My notes' example:

MyComponent.jsx
import PropTypes from "prop-types";

function MyComponent({ name, age, isActive }) {
  return (
    <div>
      <h1>{name}</h1>
      <p>Age: {age}</p>
      <p>Status: {isActive ? "Active" : "Inactive"}</p>
    </div>
  );
}

MyComponent.propTypes = {
  name: PropTypes.string.isRequired, // name must be a string and is required
  age: PropTypes.number,             // age should be a number
  isActive: PropTypes.bool,          // isActive should be a boolean
};

// <MyComponent age="thirty" />
// React 18 and earlier (dev): two warnings in the console
//   Warning: Failed prop type: The prop `name` is marked as required in `MyComponent`, but its value is `undefined`.
//   Warning: Failed prop type: Invalid prop `age` of type `string` supplied to `MyComponent`, expected `number`.
// React 19.3: renders <h1></h1><p>Age: thirty</p>… and logs nothing
What’s happening
  1. MyComponent.propTypes is a static object mapping each prop name to a validator. PropTypes.string.isRequired means "must be present and a string"; PropTypes.number means "if present, a number".
  2. In React 18 and earlier, every time the component rendered in development, React ran each validator against the actual props and logged a Failed prop type warning for each mismatch. Nothing was ever enforced: the component still rendered with the bad values.
  3. In React 19.3 I rendered <MyComponent age="thirty" /> with console.error and console.warn spied on: zero messages. The page shows an empty <h1> and Age: thirty.
  4. The two warning lines above are real output — I produced them by calling the package's own checker by hand, PropTypes.checkPropTypes(MyComponent.propTypes, { age: "thirty" }, "prop", "MyComponent"), which uses the same message format React used to print.
  5. PropTypes only ran in development, only for props that actually got rendered, and only told you after the fact. That's why types that are checked before the code runs replaced them.

The TypeScript version catches the same mistakes at compile time, in your editor, before anything runs:

Profile.tsx
type ProfileProps = {
  name: string;
  age?: number;
  isActive?: boolean;
};

export function Profile({ name, age, isActive = false }: ProfileProps) {
  return (
    <div>
      <h1>{name}</h1>
      {age !== undefined && <p>Age: {age}</p>}
      <p>Status: {isActive ? "Active" : "Inactive"}</p>
    </div>
  );
}

<Profile age={30} />;                // ❌ TS2741: Property 'name' is missing in type '{ age: number; }' but required in type 'ProfileProps'.
<Profile name="Rohit" age="thirty" />; // ❌ TS2322: Type 'string' is not assignable to type 'number'.
What’s happening
  1. ProfileProps replaces the propTypes object: name: string is required, age?: number and isActive?: boolean are optional. Defaults move into the destructuring (isActive = false), replacing defaultProps.
  2. Leaving out name fails to compile with TS2741 — you see a red underline at the call site, not a console warning at runtime.
  3. Passing "thirty" to age fails with TS2322. Both messages are real tsc output from the sandbox (TypeScript 7.0).
  4. The check covers every call site, including ones in code paths you never ran in development — which PropTypes could never do.

Where runtime checking still matters: data the compiler can't see, such as API responses, form input, localStorage, URL params. TypeScript trusts whatever type you claim for a fetch result. For that, validate at the boundary with a schema library like Zod (see Schema validation with Zod) — that's the modern replacement for the runtime half of PropTypes.

AdvancedThe PropTypes validators you'll see in old code
React
Badge.propTypes = {
  size: PropTypes.oneOf(["sm", "md", "lg"]),
  user: PropTypes.shape({ id: PropTypes.number.isRequired, name: PropTypes.string }),
  tags: PropTypes.arrayOf(PropTypes.string),
  onClick: PropTypes.func.isRequired,
  children: PropTypes.node,
};

// checkPropTypes(…, { size: "xl", user: { name: "Rohit" }, tags: ["a", 1] }, …) logs:
// Warning: Failed prop type: Invalid prop `size` of value `xl` supplied to `Badge`, expected one of ["sm","md","lg"].
// Warning: Failed prop type: The prop `user.id` is marked as required in `Badge`, but its value is `undefined`.
// Warning: Failed prop type: Invalid prop `tags[1]` of type `number` supplied to `Badge`, expected `string`.
// Warning: Failed prop type: The prop `onClick` is marked as required in `Badge`, but its value is `undefined`.
What’s happening
  1. oneOf is a union of literal values — TypeScript's "sm" | "md" | "lg". "xl" fails.
  2. shape describes an object's properties and checks them recursively — the message names the nested path, user.id.
  3. arrayOf checks every element — the message pinpoints tags[1].
  4. func, node (anything renderable — TypeScript's ReactNode), element, instanceOf, objectOf and custom validator functions cover the rest. When migrating, each maps directly onto a TypeScript type; React's upgrade guide points to a codemod, npx codemod@latest react/prop-types-typescript.

#Fragments

A component must return one root, but sometimes you want to return several siblings without adding a wrapper element. A fragment groups children for React without creating any DOM node. From my notes: fragments group multiple elements without adding extra nodes to the DOM, using React.Fragment or the shorthand <>…</>, and they help reduce unnecessary DOM nodes.

The mental model: a fragment is a paper clip, not an envelope. It holds the pages together while you hand them over, and then disappears — the receiver gets the pages, not a container.

Fragments.jsx
function Columns() {
  return (
    <>
      <td>Rohit</td>
      <td>Engineer</td>
    </>
  );
}

export function Table() {
  return (
    <table>
      <tbody>
        <tr>
          <Columns />
        </tr>
      </tbody>
    </table>
  );
}

// → <table><tbody><tr><td>Rohit</td><td>Engineer</td></tr></tbody></table>
What’s happening
  1. Columns wants to return two <td>s. Returning them side by side without a wrapper is a syntax error (JSX needs one root expression), so they're wrapped in <>…</>.
  2. The fragment compiles to an element of type Fragment. When React renders it, it renders the children and nothing for the fragment itself.
  3. The output has the two cells directly inside the <tr>, which is valid HTML.
  4. If you used a <div> instead, the DOM would be invalid — a div can't sit inside a tr. React 19.3 warns about both levels: "In HTML, <td> cannot be a child of <div>. This will cause a hydration error." and "In HTML, <div> cannot be a child of <tr>." Browsers also "fix" such HTML when parsing it, which breaks server-rendered pages. This is where fragments aren't just tidier but required.

The short syntax <>…</> can't take attributes. When you render a list of groups, each group needs a key, so use the long form:

React
import { Fragment } from "react";

export function Glossary({ terms }) {
  return (
    <dl>
      {terms.map((term) => (
        <Fragment key={term.id}>
          <dt>{term.word}</dt>
          <dd>{term.meaning}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

// → <dl><dt>JSX</dt><dd>Syntax for elements</dd><dt>Fiber</dt><dd>The reconciler</dd></dl>
What’s happening
  1. A <dl> must contain dt/dd pairs directly, so a wrapper div per term would be wrong. Each term returns a pair of siblings.
  2. map returns an array of fragments, and items in an array need a key. <Fragment key={term.id}> gives each pair its identity; key is the only attribute fragments accepted before React 19.3.
  3. The output is a flat dl with alternating dt and dd — no extra nodes.

When to use them: returning siblings from a component, keeping CSS layouts that depend on direct children (flexbox, grid, :first-child) intact, and inside elements with strict content rules (table, tr, ul, dl, select). Don't reach for a div just to satisfy the one-root rule.

AdvancedFragment refs (React 19.3)

React 19.3 made Fragment refs stable: <Fragment ref={ref}> gives you a FragmentInstance that acts on the fragment's DOM children as a group, still without adding a node. In the sandbox:

React
function Group() {
  const ref = useRef(null);
  useEffect(() => {
    ref.current.addEventListener("click", (e) => console.log(e.target.textContent));
    ref.current.focus(); // focuses the first focusable child
  }, []);
  return (
    <Fragment ref={ref}>
      <button>One</button>
      <button>Two</button>
    </Fragment>
  );
}
// ref.current.constructor.name → "FragmentInstance"
// clicking each button logs "One", "Two"; after focus(), document.activeElement is "One"
What’s happening
  1. After commit, ref.current is a FragmentInstance (not a DOM node — there isn't one). It has addEventListener, removeEventListener, dispatchEvent, focus, focusLast, blur, observeUsing, unobserveUsing, getClientRects, getRootNode, compareDocumentPosition and scrollIntoView; the test checked all twelve exist.
  2. addEventListener attaches the listener to each first-level child, so clicks on both buttons are seen.
  3. focus() moves focus to the first focusable descendant — handy for focus management in a component that has no single wrapper element.
  4. observeUsing(observer) connects an IntersectionObserver or ResizeObserver to every child, which used to need a wrapper div just to observe. This is new enough that you won't see it in most codebases yet.

#Conditional rendering

Added

Components often need to show different things depending on data: a spinner while loading, an error message, an empty state, a badge only when there are unread items. Since a component is just a function returning JSX, conditional rendering is ordinary JavaScript: if statements before the return, and expressions (? :, &&, ??) inside the JSX. There's no special v-if or *ngIf syntax.

The mental model: JSX is a value, so choosing what to render is choosing which value to return — like choosing which string to return from a function.

Conditional.jsx
export function Inbox({ status, messages, user }) {
  if (status === "loading") return <p>Loading…</p>; // early return
  if (status === "error") return null;               // render nothing

  return (
    <section>
      <h2>{user ? `Hi, ${user.name}` : "Hi, guest"}</h2>
      {messages.length > 0 && <span className="badge">{messages.length} new</span>}
      {messages.length === 0 ? <p>All caught up</p> : <ul>{messages.map((m) => <li key={m.id}>{m.subject}</li>)}</ul>}
    </section>
  );
}

// status="loading"             → <p>Loading…</p>
// status="error"               → (nothing)
// ready, no messages, no user  → <section><h2>Hi, guest</h2><p>All caught up</p></section>
// ready, 2 messages, Rohit     → <section><h2>Hi, Rohit</h2><span class="badge">2 new</span><ul><li>Offer</li><li>Interview</li></ul></section>
What’s happening
  1. Early return with if. When status is "loading", the function returns the spinner straight away and nothing below runs. This is the clearest way to handle whole-component states.
  2. return null renders nothing at all — the error case produces an empty container. (Since React 18, returning undefined is allowed too; before that React threw. Prefer null — it says "nothing" on purpose.)
  3. Ternary ? : picks between two values inside JSX: "Hi, Rohit" when user exists, "Hi, guest" when it's null. Use it when there's an "else".
  4. && renders the right side only when the left side is truthy: the badge appears with 2 messages and is absent with 0. JavaScript's a && b returns b when a is truthy and a otherwise; here a is false, which renders nothing.
  5. The second ternary chooses between the empty state and the list. All four results above are the real innerHTML from the tests.

For more than two cases, a lookup object beats nested ternaries:

React
const icons = { success: "✅", warning: "⚠️", error: "⛔" };

export function StatusIcon({ kind }) {
  return <span role="img" aria-label={kind}>{icons[kind] ?? "ℹ️"}</span>;
}
// kind="warning" → ⚠️     kind="other" → ℹ️

#Rendering lists and keys

To render a list, you turn an array of data into an array of elements, almost always with .map(). React renders arrays as siblings. Each item needs a key: a string or number that identifies that item among its siblings and stays the same across renders.

From my notes: React uses keys to identify which items in a list have changed, been added or been removed; keys help reconciliation, and incorrect keys lead to bugs. The mental model: keys are name tags at a party. Without them, React can only say "the first person, the second person…". If the first person leaves, everyone shifts up a place, and React thinks the second person is now the first — and gives them the first person's coat. With name tags, React knows exactly who left.

My notes' example uses the index as the key:

React
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo}</li> // ⚠️ fine only if the list never changes order
      ))}
    </ul>
  );
}

For plain text like that you won't see a bug, because React just updates each li's text. The bug appears as soon as a list item has state or DOM state of its own — an input, a checkbox, an open/closed toggle. Here's the bug made visible:

TodoList.jsx
function TodoRow({ todo, onRemove }) {
  return (
    <li>
      {todo.text}
      <input aria-label={`note for ${todo.text}`} placeholder="note" />
      <button onClick={() => onRemove(todo.id)}>Remove {todo.text}</button>
    </li>
  );
}

export function TodoList({ useIndexAsKey }) {
  const [todos, setTodos] = useState([
    { id: "t1", text: "Write tests" },
    { id: "t2", text: "Fix bug" },
    { id: "t3", text: "Deploy" },
  ]);
  const remove = (id) => setTodos(todos.filter((t) => t.id !== id));

  return (
    <ul>
      {todos.map((todo, index) => (
        <TodoRow key={useIndexAsKey ? index : todo.id} todo={todo} onRemove={remove} />
      ))}
    </ul>
  );
}

// type "mine" into the note for "Write tests", then remove "Write tests":
// key={index}   → ["Fix bug=\"mine\"", "Deploy=\"\""]   ❌ the note jumped to the wrong row
// key={todo.id} → ["Fix bug=\"\"",     "Deploy=\"\""]   ✅
What’s happening
  1. With key={index}, the three rows have keys 0, 1, 2. You type mine into row 0's input (the "Write tests" row). The typed text lives in that <input> DOM node.
  2. Removing "Write tests" leaves [Fix bug, Deploy]. The new keys are 0 and 1. React matches by key: key 0 still exists, so it keeps the first row's component and DOM and just updates its props to "Fix bug". Key 2 is gone, so React removes the last row.
  3. Result: the input that holds mine is still in row 0, now labelled "Fix bug". The user deleted "Write tests" but the note moved to "Fix bug" — exactly the "wrong elements may be updated" in my notes.
  4. With key={todo.id}, the keys are t1, t2, t3. After removing t1, React sees t2 and t3 still exist and keeps those rows exactly as they were, removing only the t1 row with its mine input. Both remaining notes are empty, as they should be.
  5. The key decides which component instance and DOM nodes belong to which item. The index describes a position, not an item, so it breaks whenever items are inserted, removed or reordered.

Rules for keys:

  • Unique among siblings (not globally) and stable: the same item gets the same key every render.
  • Use an id from the data: a database id, a slug, a UUID created when the item was created.
  • Index is acceptable only when the list is static: never reordered, filtered, or inserted into, and items have no state.
  • Never key={Math.random()} or a fresh crypto.randomUUID() during render: every render gets new keys, so React unmounts and remounts every item each time (the test confirms the first <li> is a different DOM node after a re-render), destroying state and wasting work.
  • The key goes on the outermost element returned from map (the TodoRow, not the li inside it).

The warnings React 19.3 prints for the two common mistakes:

  • No key: "Each child in a list should have a unique "key" prop. Check the render method of List. See https://react.dev/link/warning-keys for more information."
  • Duplicate keys: "Encountered two children with the same key, a. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version."
Index keys and checkboxes

A list renders items.map((item, i) => <Row key={i} item={item} />) where each Row has its own const [checked, setChecked] = useState(false). You tick the checkbox in the first row, then add a new item at the start of the array. Which row is ticked?

Show answer

The new first row is ticked, and the item you actually ticked (now second) isn't.

What’s happening
  1. Before: keys 0, 1, 2 for items A, B, C. Ticking row A sets checked = true in the instance with key 0.
  2. After inserting Z at the front: items Z, A, B, C with keys 0, 1, 2, 3.
  3. React matches by key: key 0 exists, so the instance with checked = true is kept and now receives item = Z. Keys 1 and 2 get A and B with their old (unticked) state, and key 3 is a new instance for C.
  4. So Z shows as ticked, A doesn't. With key={item.id}, the ticked instance stays attached to A wherever it moves.

#Designing reusable components

A reusable component is one other people can drop into situations you didn't plan for without editing its code. My notes list the principles for reusable components (from the "creating reusable frontend components (SDKs)" section):

  • Separation of concerns: each component should do one thing well. Don't mix data fetching and UI rendering in the same component.
  • Props-driven design: make components customisable through props, so they adapt to different use cases without changes to their internals.
  • State management: keep components as stateless as possible. If state is required, let the parent control it, or lift it up.

The mental model: a reusable component is like a power socket — a small, stable, documented interface (its props) that anything can plug into, with the wiring hidden behind the wall. The fewer assumptions it makes about who plugs in, the more places it works.

My notes had a "reusable button component" heading whose example was a screenshot that didn't come through, so here's one written from scratch:

Button.jsx
export function Button({
  variant = "primary",
  size = "md",
  loading = false,
  type = "button",
  className = "",
  disabled,
  children,
  ...rest
}) {
  const classes = ["btn", `btn-${variant}`, `btn-${size}`, className].filter(Boolean).join(" ");

  return (
    <button
      type={type}
      className={classes}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      {...rest}
    >
      {loading ? "Saving…" : children}
    </button>
  );
}

// <Button onClick={save}>Save</Button>
// → <button type="button" class="btn btn-primary btn-md">Save</button>
// <Button variant="danger" size="sm" className="ml-2" aria-label="Delete item">🗑</Button>
// → <button type="button" class="btn btn-danger btn-sm ml-2" aria-label="Delete item">🗑</button>
// <Button loading>Save</Button>
// → <button type="button" class="btn btn-primary btn-md" disabled="" aria-busy="true">Saving…</button>
What’s happening
  1. A small set of named options with defaults: variant and size map to CSS classes (btn-primary btn-md). Callers pick from a closed set instead of passing arbitrary styles, which keeps the design consistent.
  2. type = "button" by default. A plain <button> inside a form defaults to type="submit" and submits the form on click — a classic bug in reusable buttons. Callers that want submit say so: <Button type="submit">.
  3. className is merged, not replaced, so a caller can add spacing (ml-2) without losing the base classes. .filter(Boolean) drops the empty string when no class is passed.
  4. ...rest forwards everything else — onClick, aria-label, data-testid, form, title — to the real <button>. The component supports every native button feature without listing them, and accessibility attributes just work.
  5. loading derives the rest: it disables the button, sets aria-busy so screen readers know, and swaps the label. aria-busy={loading || undefined} leaves the attribute out entirely when not loading (React omits attributes whose value is undefined).
  6. children is the label, so it can be text, an icon, or both. The test clicks Save once and checks onClick ran once, and that the loading button is disabled.

Composing small reusable pieces into a bigger one — this is the Form example from my notes ("Form component composed of smaller components"), made runnable:

Form.jsx
import { useId } from "react";
import { Button } from "./Button.jsx";

export function Input({ label, error, ...inputProps }) {
  const id = useId();
  const errorId = `${id}-error`;
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <input id={id} aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} {...inputProps} />
      {error && <p id={errorId} className="field-error">{error}</p>}
    </div>
  );
}

export function LoginForm({ onSubmit, saving, error }) {
  const handleSubmit = (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    onSubmit({ username: data.get("username"), password: data.get("password") });
  };

  return (
    <form onSubmit={handleSubmit}>
      <Input label="Username" name="username" />
      <Input label="Password" name="password" type="password" error={error} />
      <Button type="submit" loading={saving}>Submit</Button>
    </form>
  );
}
What’s happening
  1. Input owns one concern: a labelled field with an optional error. useId() generates an id that's unique per instance (_r_0_, _r_1_ in the test output), so two Inputs on one page never clash and <label htmlFor> always points at the right input (see useId).
  2. When error is set, Input adds aria-invalid and links the message with aria-describedby. The test checks the password field's accessible description is Wrong password — accessibility is built in once instead of remembered by every caller.
  3. LoginForm composes two Inputs and a Button. It doesn't style anything or manage ids; it only knows what fields the form has and what to do on submit.
  4. LoginForm doesn't keep the field values in state — it reads them from the form with FormData on submit (an uncontrolled form, see Controlled vs uncontrolled components). It doesn't call the API either: onSubmit is a prop, so the same form works with a real backend, a mock in a test, or a story in Storybook. That's the separation of concerns from my notes.
  5. Typing rohit / pw and clicking Submit calls onSubmit({ username: "rohit", password: "pw" }) — the test checks exactly that.

A checklist for a component other people will use:

  • Few required props, sensible defaults for everything else.
  • Forward the rest (...rest) to the main DOM element, and accept className (and ref in React 19) so callers can integrate it.
  • Use children for content rather than a dozen text props.
  • Don't fetch data inside UI components; take data and callbacks as props, and keep fetching in a page component or a hook.
  • Support controlled and uncontrolled use for anything with a value (below).
  • Accessible by default: real <button>s, labels linked to inputs, aria-* handled inside.
  • Name events after what happened (onSelect, onClose), not how (handleClick).
AdvancedSupporting both controlled and uncontrolled use

"If state is required, ensure the component allows external control" from my notes is a specific pattern: the component keeps its own state unless the parent passes a value, in which case the parent's value wins.

Toggle.jsx
import { useState } from "react";

// Works controlled (pass `on` + `onChange`) or uncontrolled (pass `defaultOn`, or nothing)
export function Toggle({ on, defaultOn = false, onChange, label }) {
  const [internalOn, setInternalOn] = useState(defaultOn);
  const isControlled = on !== undefined;
  const value = isControlled ? on : internalOn;

  const flip = () => {
    if (!isControlled) setInternalOn(!value);
    onChange?.(!value);
  };

  return (
    <button role="switch" aria-checked={value} onClick={flip}>
      {label}: {value ? "On" : "Off"}
    </button>
  );
}

// <Toggle label="Wifi" />                         click → "Wifi: On"        (uncontrolled)
// <Toggle label="Dark mode" on={dark} onChange={setDark} />  dark=true, click → "Dark mode: Off"
// <Toggle label="Locked" on={false} onChange={log} />  click → still "Locked: Off", log(true) called
What’s happening
  1. isControlled is true when the parent passes on. Then the displayed value is the parent's on, and internalOn is ignored.
  2. Uncontrolled ("Wifi"): no on, so value comes from internal state. A click calls setInternalOn(true) and the switch shows On. The parent can still listen through onChange, which the test records as ["wifi", true].
  3. Controlled ("Dark mode"): the parent holds dark = true and passes setDark as onChange. A click calls onChange(false); the parent updates its state; the new on={false} flows down and the switch shows Off.
  4. Controlled but parent ignores the change ("Locked"): the click calls onChange(true), but the parent keeps passing on={false}, so the switch stays Off. The parent has the final say — which is what lets it validate or veto changes.
  5. This is the same contract as <input value onChange> vs <input defaultValue>, and what libraries like Radix and MUI do for every stateful component. Don't let a component switch between modes during its life; React warns about exactly that for inputs.

When a set of reusable components becomes a shared package for several apps — packaging, tree shaking, versioning, theming and documentation — see Building a reusable component library.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.