React NotesRohit’s interview study guide
Chapter 01

React Fundamentals

What React is, what JSX compiles to, how the Virtual DOM and reconciliation decide what to change, one-way data flow, mounting an app, StrictMode, project setup and how React got from 16 to 19.3.

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

#What React is and why it exists

Added

React is a JavaScript library for building user interfaces out of components. You describe what the screen should look like for the current data, and React works out which DOM changes are needed to get there. When the data changes, you describe the screen again; React compares the new description with the old one and applies only the difference.

The problem it solves is keeping the screen in sync with the data. In plain DOM code every change is a set of manual instructions — "find this span, change its text, enable that button" — and every place that changes the data has to remember every place on the screen that shows it. With a few dozen interacting pieces of state, those instructions become the main source of bugs. React swaps imperative code ("how to change the page") for declarative code ("what the page is for this data").

A good mental model is a spreadsheet. You don't tell a spreadsheet "when A1 changes, go and rewrite C1". You write the formula C1 = A1 * B1 once, and the spreadsheet keeps it true. A React component is that formula: UI = f(state). You change the inputs; React recomputes the output and patches the page.

counter-vanilla.js
export function mountCounter(container) {
  let count = 0;
  const label = document.createElement("span");
  const button = document.createElement("button");
  label.textContent = `Count: ${count}`;
  button.textContent = "Add one";
  button.addEventListener("click", () => {
    count++;
    label.textContent = `Count: ${count}`; // you must remember to update the DOM yourself
  });
  container.append(label, button);
}
What’s happening
  1. mountCounter creates the two DOM nodes itself and writes the starting text, Count: 0. The data (count) and the screen (label.textContent) are two separate things that you keep in step by hand.
  2. On each click the listener does two jobs: count++ changes the data (0 → 1 → 2), and the next line copies the new value into the DOM.
  3. Delete that second line and count still goes up, but the page stays on Count: 0 — nothing links the two. Add a second place that shows the count (a badge, a page title) and every handler must update it too.
  4. This is imperative UI: you describe each step of how to move the page from one state to the next. It's fine for one counter and hard to keep correct for a whole app.

The same counter in React:

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

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <>
      <span>Count: {count}</span>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </>
  );
}
What’s happening
  1. Counter is a function that returns a description of the UI (JSX), not DOM nodes. On the first render count is 0, so the description is "a span saying Count: 0 and a button".
  2. React turns that description into real DOM: <span>Count: 0</span><button>Add one</button> (the test prints exactly this innerHTML after two clicks, with 2 in it).
  3. A click calls setCount(count + 1). That doesn't touch the DOM — it tells React "the state is now 1, please render again".
  4. React calls Counter again. This time count is 1, so the function returns a span saying Count: 1. React compares it with the previous description, sees that only the text changed, and updates just that text node.
  5. There is no line that updates the span. Whatever count is, the span shows it, because the span is computed from count. That's the declarative model: you describe the result and React does the steps.

From my notes, the key features of React, with the wording tightened:

  • JSX — a syntax extension that lets you write the UI description as HTML-like markup inside JavaScript. It compiles to plain function calls (see JSX).
  • Virtual DOM — React's in-memory tree of plain objects describing the UI. My notes call it "a lightweight copy of the real DOM"; more precisely it's a description of what the DOM should be, not a copy of it (see The Virtual DOM and reconciliation).
  • Components — reusable functions that take inputs (props) and return UI.
  • One-way data binding — data flows down from parents to children through props; children ask for changes through callbacks (see One-way data flow).
  • Hooks — functions such as useState and useEffect that give function components state, side effects and other React features.

Where React sits in practice:

  • It's a library, not a framework. React itself only renders components and manages their state. Routing, data fetching, forms and styling come from other libraries (React Router, TanStack Query, React Hook Form…), or from a framework built on React such as Next.js or React Router's framework mode, which also adds server rendering.
  • It's not only for the web. The component model is shared; the renderer changes. react-dom renders to the browser DOM, React Native renders to iOS/Android views.
  • When it's overkill: a mostly static page with a little interactivity doesn't need a client-side React app; plain HTML, or a framework that renders React on the server and ships little JavaScript, serves it better.

#React compared with Angular, Vue and React Native

Added

"Why React and not Angular?" and "Have you used Vue?" come up in almost every front-end interview. The useful answer isn't which one is better. It's what each one ships, how you write the UI, and how a change in data reaches the screen. React, Vue and Angular all build UIs from components that update when their state changes; they differ in how much comes in the box and in the mechanism behind "update". React Native isn't a rival at all: it's React itself, drawing native mobile views instead of DOM elements.

A mental model: Angular is a fully fitted kitchen: oven, fridge, knives and a manual for how to cook, all from one maker and upgraded together. React is an excellent stove: you pick the fridge and knives yourself, or buy a fitted kitchen built around the stove (Next.js). Vue sits in between: a stove plus an optional set of matching appliances from the same maker (Vue Router, Pinia). React Native is the same chef in a different kitchen: the recipes (components, props, state, hooks) are the same, but the ingredients are View and Text instead of div and span.

Library vs framework

React the package does two things: it lets you describe UI as components, and it keeps the screen in step with their state (react-dom does the DOM part). Everything else is a separate choice: routing (React Router, TanStack Router), server data (TanStack Query), forms (React Hook Form), global state (Redux Toolkit, Zustand), styling, and the build tool. For a new app, react.dev recommends starting from a framework built on React, such as Next.js or React Router's framework mode, which makes those choices for you and adds server rendering (see What React is and why it exists).

Angular is a framework: one @angular/* release train, generated and upgraded by the Angular CLI (ng new, ng update). Built in, from the same team:

  • Router (@angular/router) with guards, resolvers and lazy-loaded routes.
  • Forms: template-driven (ngModel) and reactive forms (FormGroup, FormControl), plus the newer signal-based forms.
  • HTTP: HttpClient with interceptors.
  • Dependency injection: services are created and handed to components by the framework (inject(UserService)), which is also how you swap them for fakes in tests.
  • Reactivity: RxJS observables throughout the older APIs, and signals (signal, computed, effect) since Angular 16; zoneless change detection is the default from Angular 21.
  • The CLI's build, dev server, test runner setup and i18n tooling.

Vue is "progressive": the core is a view layer like React's, but the router (Vue Router) and the state library (Pinia) are official, maintained by the core team, and offered by the create-vue scaffold. Nuxt is Vue's equivalent of Next.js.

The trade-off: Angular's batteries-included approach makes every Angular app look alike, so you can move between teams, and upgrades are coordinated. React's approach lets each team pick the best tool for each job, but every team assembles, documents and upgrades its own stack, and two React codebases can look very different.

The same component three ways

A button that counts clicks and a text input that greets you. In React:

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

export function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>Hello, {name}</p>
    </>
  );
}

// after two clicks and typing "Rohit":
// <button>Clicked 2 times</button><input value="Rohit"><p>Hello, Rohit</p>
What’s happening
  1. The component is one JavaScript function. useState(0) and useState("") give it two pieces of state; the markup is JSX, so {count} and {name} are ordinary JavaScript expressions.
  2. A click calls setCount(count + 1). Nothing on the page changes directly: React re-runs Counter, gets a button saying Clicked 1 times, and patches that text node.
  3. The input is controlled: value={name} pushes state into the input, and onChange pushes every keystroke back into state. Typing R calls setName("R"), React re-renders, and the input and the <p> both show the new value. Five keystrokes later name is "Rohit".
  4. Both directions are written out by hand. That's React's one-way data flow: there is no syntax that ties the input to the variable for you (see Controlled vs uncontrolled components).
  5. The test clicked twice, typed "Rohit" and printed the innerHTML shown in the comment.

In Vue, as a single-file component (.vue): the script, the template and optionally the styles live in one file.

Counter.vue
<script setup>
import { ref } from "vue";

const count = ref(0);
const name = ref("");
</script>

<template>
  <button @click="count++">Clicked {{ count }} times</button>
  <input v-model="name" />
  <p>Hello, {{ name }}</p>
</template>
What’s happening
  1. ref(0) creates a reactive value: an object whose .value Vue watches. In the template you write count, not count.value; the template compiler unwraps refs for you.
  2. @click="count++" is shorthand for v-on:click. Mutating the ref is the whole update: Vue noticed during the last render that this component's template read count, so changing it schedules a re-render of exactly this component.
  3. v-model="name" is two-way binding: it expands to :value="name" plus an @input handler that assigns name = $event.target.value. It's the same two arrows as React's controlled input, generated by the compiler.
  4. {{ }} is template interpolation. Templates are HTML with directives (v-if, v-for, :prop, @event), compiled to render functions at build time.
  5. Not compiled as a .vue file here (that needs Vue's Vite plugin). The same setup() refs and the same template were run with Vue 3.5 in the sandbox through defineComponent, and after two clicks and typing "Rohit" rendered <button>Clicked 2 times</button><input><p>Hello, Rohit</p>.

In Angular 22, as a standalone component with signals:

counter.ts
import { Component, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";

@Component({
  selector: "app-counter",
  imports: [FormsModule],
  template: `
    <button (click)="count.set(count() + 1)">Clicked {{ count() }} times</button>
    <input [(ngModel)]="name" />
    <p>Hello, {{ name() }}</p>
  `,
})
export class Counter {
  count = signal(0);
  name = signal("");
}
What’s happening
  1. A component is a class with a decorator. The decorator carries the metadata: the tag name (<app-counter>), what the template may use (FormsModule for ngModel) and the template itself.
  2. signal(0) is Angular's reactive value. You read it by calling it, count(), and write it with .set() or .update(). Reading a signal in a template links that template to the signal, so a change marks this component for refresh.
  3. (click)="…" is an event binding (round brackets: data goes out of the view). {{ count() }} and [prop]="…" are property bindings (data goes in).
  4. [(ngModel)]="name" combines both, the "banana in a box": [ngModel] pushes name into the input and (ngModelChange) writes the typed value back. Since Angular 17.2 the target can be a writable signal, as here.
  5. Compiled and tested with TestBed in the Angular 22 sandbox: two clicks, then typing "Rohit", gave <p>Hello, Rohit</p> and name() equal to "Rohit".

JSX vs templates

JSX is JavaScript: a list is items.map(…), a condition is && or a ternary, and you can pull any piece out into a variable or a function. TypeScript checks it like any other code. Templates are HTML with a small extra language: Vue's v-if and v-for, Angular's @if and @for blocks (Angular 17+; older code uses *ngIf and *ngFor). Templates can do less, but because they're more constrained, the compiler can analyse them: Vue marks the parts that can never change and skips them when diffing, and Angular turns templates into direct DOM-update instructions. Vue also supports JSX and render functions, and Angular's strict template type checking catches template mistakes at build time, so in practice the choice is a matter of style more than capability.

How a change reaches the screen

This is the difference that matters most for performance work:

  • React re-renders and reconciles. A state update re-runs that component and, by default, every component below it, then compares the new element tree with the previous one and commits only the DOM changes (see The Virtual DOM and reconciliation). React doesn't know which values a component used; it asks again "what should this look like now?". You skip work with React.memo or let the React Compiler memoize for you.
  • Vue tracks dependencies. ref and reactive are proxies: during a render Vue records which reactive values each component read. Changing one re-renders only the components that read it. A child whose props didn't change isn't re-rendered. Each component still diffs a virtual DOM inside itself; Vue 3.6 (a release candidate at the time of writing) adds an opt-in Vapor mode that compiles templates without one.
  • Angular checks bindings, and signals narrow the check. There's no virtual DOM: the compiled template updates its bindings in place. Classic Angular used zone.js to notice "something async happened" and then checked the component tree top-down (with OnPush to skip subtrees). With signals and zoneless change detection, a signal change schedules a refresh only for the views that read it.

Mental model: React re-asks every component in the subtree and diffs the answers; Vue and Angular signals keep a list of who used each value and notify only them. Neither model is "faster" in general. Benchmarks depend on the app, and all three are fast enough for nearly everything. The practical difference is how much you think about it: in React you sometimes memoize, in Vue you rarely need to, in Angular you use OnPush or signals.

AdvancedWatching the difference: render counts

The same tree in React and Vue: a parent with a counter, a child that shows the count, and a sibling that doesn't use it. Each component increments a counter every time it renders; then the button is clicked three times.

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

export const renders = { parent: 0, shows: 0, other: 0 };

function ShowsCount({ count }) {
  renders.shows++;
  return <p>Count: {count}</p>;
}

function Other() {
  renders.other++;
  return <p>I don't use count</p>;
}

export function Parent() {
  const [count, setCount] = useState(0);
  renders.parent++;
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <ShowsCount count={count} />
      <Other />
    </>
  );
}

// after 3 clicks, React: {"parent":4,"shows":4,"other":4}
// the same tree in Vue 3.5:  {"parent":4,"shows":4,"other":1}
What’s happening
  1. Mount: every component renders once in both libraries, so all three counters are 1.
  2. Each click in React updates Parent's state, so React re-runs Parent and then every child it returns, including Other, which takes no props at all. Three clicks: 1 → 4 for all three.
  3. The Vue version (same tree written with defineComponent and h() render functions so it could count renders) re-renders Parent because its render read count, and ShowsCount because its count prop changed. Other has no props that changed and reads no reactive state, so Vue skips it: it stays at 1.
  4. React's output is still correct: re-rendering Other produced the same elements, so nothing in the DOM changed. The cost is only the extra function call and diff, which is usually tiny.
  5. Wrap Other in memo(...) and React matches Vue: the sandbox run gave {"parent":4,"shows":4,"other":1}. The React Compiler does this kind of memoization automatically.

State management

NeedReactVueAngular
Local stateuseState, useReducerref, reactivesignals, class fields
Shared without a libraryContext (The Context API)provide / injectan injectable service holding signals
Global storeRedux Toolkit, Zustand, Jotai, MobXPinia (official). Vuex is the legacy store, now in maintenance modeNgRx (Redux-style store on RxJS, or NgRx SignalStore), or plain services
Server dataTanStack Query, RTK QueryTanStack Query (Vue), Pinia ColadaHttpClient with RxJS, httpResource

The ideas carry over: Pinia stores and NgRx feature stores are the same "keep shared state outside the components and change it through named operations" idea as Redux and Zustand (see Choosing a state management approach).

React Native: same React, different renderer

React Native uses the same react package: components, props, state, hooks, context, Redux and TanStack Query all work as on the web. What changes is the renderer and the building blocks. Instead of react-dom creating DOM nodes, React Native's renderer creates real platform views: a UIView on iOS, an Android View. There's no HTML and no browser.

NativeCounter.jsx
import { useState } from "react";
import { View, Text, Pressable, TextInput, StyleSheet } from "react-native";

export function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");

  return (
    <View style={styles.box}>
      <Pressable onPress={() => setCount(count + 1)}>
        <Text>Clicked {count} times</Text>
      </Pressable>
      <TextInput value={name} onChangeText={setName} style={styles.input} />
      <Text>Hello, {name}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { padding: 16, gap: 8 },
  input: { borderWidth: 1, borderColor: "#999", padding: 8 },
});
What’s happening
  1. The state logic is identical to the web Counter: the same two useState calls, the same setCount(count + 1). That part would pass a code review on either platform.
  2. The elements are different. View replaces div, Text replaces span and p (all text must sit inside a <Text>), Pressable replaces button, and TextInput replaces input.
  3. Events are named for touch and native controls: onPress instead of onClick, and onChangeText, which hands you the string directly, instead of onChange with e.target.value. That's why setName can be passed straight in.
  4. Styles are JavaScript objects, not CSS: StyleSheet.create with camelCase properties and unitless numbers (density-independent pixels). Layout is Flexbox only, with flexDirection: "column" as the default, and styles don't cascade from parent to child.
  5. Tested by rendering it with react-native-web (which maps these primitives to DOM elements) in the sandbox: two presses and typing "Rohit" gave Clicked 2 times and Hello, Rohit. It was not run on a device or simulator.

The other differences you'll be asked about:

  • No DOM or browser APIs: no document, no window.localStorage (use AsyncStorage or MMKV), no CSS files or className.
  • Navigation is a library: React Navigation (stack, tab and drawer navigators that behave like native screens) or Expo Router (file-based, built on React Navigation). React Router's web model of URLs doesn't fit mobile screen stacks.
  • Tooling: the React Native docs recommend starting with Expo, a framework for React Native much as Next.js is for the web. Code runs on the Hermes JavaScript engine, and the "New Architecture" (the Fabric renderer, with JSI replacing the old asynchronous bridge) is the default since React Native 0.76.
  • Code sharing has limits: hooks, stores, API clients, validation schemas and types are plain JavaScript and share well across web and mobile, often in a monorepo package. UI components don't, because <div className> and <View style> are different worlds. You can share UI by writing it with React Native primitives and rendering it on the web with react-native-web (how Expo's web support works), or keep per-platform files (Button.web.jsx, Button.native.jsx, Button.ios.jsx) that the bundler picks automatically. The realistic plan is "share the logic, write the screens per platform".

At a glance

ReactVueAngularReact Native
What it isUI libraryProgressive frameworkFull frameworkReact with a native renderer
UI written inJSXTemplates in .vue files (JSX possible)Templates with a decorated TypeScript classJSX with View, Text…
Form input bindingControlled inputs: value + onChangev-model (two-way)[(ngModel)] or reactive formsvalue + onChangeText
How updates happenRe-render subtree, diff, commitDependency tracking per component + virtual DOMCompiled bindings; signals mark views to refreshSame as React
RoutingReact Router, TanStack Router, Next.jsVue Router (official)@angular/router (built in)React Navigation, Expo Router
HTTPfetch, TanStack Queryfetch, TanStack QueryHttpClient (built in)fetch, TanStack Query
Global stateRedux Toolkit, ZustandPiniaServices with signals, NgRxSame as React
StylingAny: CSS Modules, Tailwind, CSS-in-JS<style scoped> in the .vue fileComponent styles, encapsulated by defaultStyleSheet objects, Flexbox
Meta-frameworkNext.js, React Router framework modeNuxtBuilt-in SSR (@angular/ssr); AnalogExpo

#JSX

JSX is a syntax extension for JavaScript that looks like HTML and describes what a component should render. Browsers can't run it; a compiler (Babel, esbuild, Oxc, SWC, TypeScript) turns every tag into a function call before the code ships. So JSX is not a template language with its own rules — it's JavaScript with a different way to write one kind of function call, and anything you can do with a JavaScript value you can do with JSX.

The mental model: a JSX tag is an expression that produces an object, like [1, 2] produces an array. That's why you can store it in a variable, return it, pass it as a prop or put it in an array. And curly braces { } are the way back from markup into JavaScript: anything inside them is a JavaScript expression whose value goes into the output.

ProfileCard.jsx
export function ProfileCard({ user }) {
  const initials = user.name
    .split(" ")
    .map((part) => part[0])
    .join("");

  return (
    <article className="card" style={{ borderColor: user.online ? "green" : "grey", padding: 12 }}>
      {/* a comment inside JSX goes in braces */}
      <h2>{user.name.toUpperCase()}</h2>
      <span title="initials">{initials}</span>
      <label htmlFor="bio">Bio</label>
      <textarea id="bio" defaultValue={user.bio} />
      <p>{user.online ? "Online" : "Offline"}</p>
      <ul>
        {user.skills.map((skill) => (
          <li key={skill}>{skill}</li>
        ))}
      </ul>
    </article>
  );
}

// <ProfileCard user={{ name: "Rohit Varma", online: true, bio: "Android + web", skills: ["React", "Kotlin"] }} />
// renders:
// <article class="card" style="border-color: green; padding: 12px;"><h2>ROHIT VARMA</h2>
// <span title="initials">RV</span><label for="bio">Bio</label><textarea id="bio">Android + web</textarea>
// <p>Online</p><ul><li>React</li><li>Kotlin</li></ul></article>
What’s happening
  1. Above the return is ordinary JavaScript: initials is computed from "Rohit Varma" → ["Rohit", "Varma"] → ["R", "V"] → "RV". Logic lives in plain JS; JSX just uses the result.
  2. className="card" becomes the HTML class attribute and htmlFor="bio" becomes for. JSX attributes follow the DOM property names, and class and for are reserved words in JavaScript, which is why they're spelled differently.
  3. style takes an object, not a string — hence the double braces: the outer pair means "JavaScript expression", the inner pair is the object literal. Keys are camelCase (borderColor), and a bare number like padding: 12 gets px added, giving padding: 12px.
  4. {user.name.toUpperCase()}, {initials} and {user.online ? "Online" : "Offline"} are expressions; their values are inserted as text. You can't put an if statement inside braces — only expressions — which is why the ternary is used.
  5. {user.skills.map(...)} produces an array of elements, and React renders arrays as siblings: two <li>s. Each needs a key (see Rendering lists and keys).
  6. Every tag must be closed (<textarea ... />), and the component returns one root element (<article>). The parentheses around the multi-line JSX stop automatic semicolon insertion from ending the return on its own line.

The rules that trip people up:

  • One root. A component returns a single element. To return siblings without a wrapper div, use a fragment <>…</> (see Fragments).
  • Close everything: <img />, <br />, <input />.
  • camelCase attributes and events: tabIndex, onClick, autoFocus. Exceptions: aria-* and data-* keep their dashes.
  • Comments are {/* … */} inside JSX.
  • Strings vs expressions: size="48" passes the string "48"; size={48} passes the number.
What does this render?
React
<div>
  {true}{false}{null}{undefined}{0}{""}{NaN}{[1, 2, 3]}
</div>
Show answer

The text content is "0NaN123".

What’s happening
  1. true, false, null and undefined render nothing. That's deliberate: it's what makes {isOpen && <Modal />} and {error ?? null} work.
  2. 0 is a number, and numbers render as text, so "0" appears. This is the source of the classic {items.length && <List />} bug, which shows a stray 0 when the list is empty (see Conditional rendering).
  3. "" renders an empty text node — invisible.
  4. NaN is also a number, so it renders as the text "NaN".
  5. An array renders each item in order, so [1, 2, 3] gives "123".
  6. What you can't render is a plain object: <p>{{ name: "Rohit" }}</p> throws Objects are not valid as a React child (found: object with keys {name}). If you meant to render a collection of children, use an array instead.

#JSX without the syntax: createElement

Every JSX tag compiles to a function call that creates a React element: a small, plain JavaScript object saying "a div with these props and these children". Before JSX was common, and in any file where you don't use a compiler, you write those calls yourself with React.createElement(type, props, ...children).

Knowing this removes the mystery from JSX. An element is not a DOM node and not a component instance — it's a lightweight description that React reads later. Creating one is cheap; nothing happens on screen until React renders it into a root.

From my notes, this is the "without JSX" version — but it has two bugs, which I've corrected below:

JavaScript
// from my notes — doesn't work in React 19
const text = React.createElement('p', {}, 'This is a text');
const container = React.createElement('div', '{}', text); // ❌ props is the *string* '{}'
ReactDOM.render(container, rootElement);                  // ❌ removed in React 19

The second argument must be a props object or null, not the string '{}'. In React 19.3 the development build actually throws on it: TypeError: Cannot use 'in' operator to search for '__self' in {} (React checks the props object for special dev-only keys, and the in operator only works on objects). And ReactDOM.render was removed in React 19 (see Rendering an app: createRoot vs ReactDOM.render). The corrected version:

create-element.js
import { createElement } from "react";

const text = createElement("p", null, "This is a text");
const container = createElement("div", { className: "box", id: "main" }, text);

console.log(container);
// {
//   '$$typeof': Symbol(react.transitional.element),
//   type: 'div',
//   key: null,
//   props: {
//     className: 'box',
//     id: 'main',
//     children: { '$$typeof': Symbol(react.transitional.element), type: 'p', key: null, props: [Object], … }
//   },
//   _owner: null,
//   _store: {}
// }
console.log(container.props.children === text); // true

// To show it: createRoot(rootElement).render(container);
// → <div class="box" id="main"><p>This is a text</p></div>
What’s happening
  1. createElement("p", null, "This is a text") takes the type ("p"), the props (null = none) and any number of children after that. It returns an object with type: 'p' and props: { children: 'This is a text' } — the children end up inside props.
  2. createElement("div", { className: "box", id: "main" }, text) does the same with real props. The text element becomes props.children, which is why the second console.log prints true: the parent holds the very same object.
  3. The logged object is the whole "Virtual DOM node": type, key (null because none was given), and props. _owner and _store are development-only bookkeeping used for warnings.
  4. $$typeof is a Symbol marking the object as a genuine React element. Rendering this object (with createRoot(...).render(container)) produces <div class="box" id="main"><p>This is a text</p></div> — byte-for-byte the same HTML as writing the JSX <div className="box" id="main"><p>This is a text</p></div>, which the test checks.
  5. The type can also be a component function: createElement(Badge, { label: "online" }) gives an element whose type is the Badge function. React calls it later, during rendering — you never call it yourself.

Here's what a compiler actually emits for a small component. I compiled this with Oxc (the compiler Vite 8 uses) in both modes:

Card.jsx
export function Card({ user }) {
  return (
    <div className="card" onClick={() => select(user.id)}>
      <h1>Hello, {user.name}</h1>
      <Avatar user={user} size={48} />
    </div>
  );
}
JavaScript
// Automatic runtime — React 17+ (the default everywhere today)
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
export function Card({ user }) {
	return /* @__PURE__ */ _jsxs("div", {
		className: "card",
		onClick: () => select(user.id),
		children: [/* @__PURE__ */ _jsxs("h1", { children: ["Hello, ", user.name] }), /* @__PURE__ */ _jsx(Avatar, {
			user,
			size: 48
		})]
	});
}

// Classic runtime — how JSX compiled before React 17
export function Card({ user }) {
	return /* @__PURE__ */ React.createElement("div", {
		className: "card",
		onClick: () => select(user.id)
	}, /* @__PURE__ */ React.createElement("h1", null, "Hello, ", user.name), /* @__PURE__ */ React.createElement(Avatar, {
		user,
		size: 48
	}));
}
What’s happening
  1. Lowercase tags become strings ("div", "h1") — built-in DOM elements. Capitalised tags become references to variables (Avatar). That's the whole reason component names must start with a capital letter: <avatar /> would compile to the string "avatar" and render an unknown HTML tag.
  2. Attributes become a props object: className: "card", the arrow function for onClick, and {user} → user (shorthand property). size={48} stays the number 48.
  3. The text Hello, and the expression {user.name} become two children, ["Hello, ", user.name] — that's why the DOM ends up with two separate text nodes.
  4. Automatic runtime (React 17+): the compiler adds import { jsx, jsxs } from "react/jsx-runtime" for you and puts children inside the props object. jsxs is used when there are several static children, jsx for one.
  5. Classic runtime (React 16 and earlier): every tag becomes React.createElement(...), so the variable React had to be in scope in every file that used JSX — hence the import React from 'react' at the top of every old component. Run the classic output without that import and you get ReferenceError: React is not defined.
  6. /* @__PURE__ */ tells the minifier the call has no side effects, so an element that's never used can be dropped from the bundle.
AdvancedWhy $$typeof exists, and what happens to key and ref

$$typeof is a security measure. Imagine a server that stores user-supplied JSON and the app renders it: <div>{message}</div>. If an attacker could make message an object shaped like an element — { type: "img", props: { src: "x", onError: … } } — React would render it. But JSON can't contain Symbols, so an object parsed from JSON can never carry $$typeof: Symbol(react.transitional.element), and React refuses to treat it as an element. (React 19 renamed the symbol from react.element to react.transitional.element, which also lets React tell its own elements apart from ones created by an older, incompatible copy of React.)

Two props are special. key is pulled out of props onto the element, and ref is now an ordinary prop:

React
const el = <input key="a" ref={ref} placeholder="x" />;
el.key;                // "a"
Object.keys(el.props); // ["ref", "placeholder"] — no "key"
el.ref;                // still returns ref, but logs:
// "Accessing element.ref was removed in React 19. ref is now a regular prop.
//  It will be removed from the JSX Element type in a future release."
What’s happening
  1. key is for React's list reconciliation only, so the element stores it separately (el.key === "a") and the component never sees it in its props.
  2. In React 18 and earlier, ref was treated the same way: stored as el.ref, hidden from props, which is why function components needed forwardRef to receive one.
  3. In React 19, ref stays in props (["ref", "placeholder"]), so a function component can simply accept ref as a prop (see ref as a prop (React 19)). Reading the old el.ref field still works for now but logs the deprecation warning above, quoted from React 19.3.

#The Virtual DOM and reconciliation

The Virtual DOM is the tree of React elements your components return: plain objects that describe what the DOM should look like. Building and comparing these objects is cheap; touching the real DOM (creating nodes, changing attributes, triggering layout) is comparatively expensive. So instead of rebuilding the page on every change, React keeps the previous tree, builds a new one, and reconciles them: works out the smallest set of DOM operations that turns the old result into the new one.

The flow from my notes, with the details filled in:

  1. Initial render — React calls your components, gets the first element tree, and creates the matching DOM nodes.
  2. State change — something calls a state setter (a click, a timer, a response arriving).
  3. New Virtual DOM — React re-renders the component whose state changed and its descendants (not the whole app), producing a new element tree for that part.
  4. Diffing — React compares the new tree with the previous one, node by node.
  5. DOM update (commit) — only the differences are applied to the real DOM.

A mental model: it's like "track changes" on a document. React keeps the last version, you hand it a complete new version, and it applies only the edits.

Greeting.jsx
export function Greeting({ name, time }) {
  return (
    <div className="greeting">
      <h1>Hello, {name}</h1>
      <p>It is {time}</p>
    </div>
  );
}

// In the test, a MutationObserver records every change React makes to the DOM:
// rerender(<Greeting name="Rohit" time="10:00" />)  → mutations: []
// rerender(<Greeting name="Rohit" time="10:01" />)  → mutations: ["characterData: \"10:01\""]
// The <h1> node before and after is the same object.
What’s happening
  1. The first render creates a div, an h1 and a p. The p holds two text nodes, "It is " and "10:00", because JSX turned It is {time} into two children.
  2. Re-rendering with the same props makes React build a new element tree and compare it with the old one. Every type, prop and text matches, so the MutationObserver records zero DOM mutations. Re-rendering is not the same as re-painting.
  3. Re-rendering with time="10:01" produces a tree that differs in exactly one place: the second text node of the p. React records one characterData mutation, "10:00" → "10:01", and leaves everything else alone.
  4. The <h1> element is the same DOM node object before and after (toBe(h1) passes). React updates nodes in place rather than recreating them, which is also why focus, scroll position and text selection survive re-renders.
  5. That's reconciliation: you return the whole UI description every time, and React turns "here's the new picture" into "change this one text node".

To make comparing two trees fast, React uses two assumptions (a full tree diff would be O(n³); these make it O(n)):

  • Different type → different tree. If an element changes type (div → section, or ProfileA → ProfileB), React destroys the old subtree — DOM nodes and component state — and builds the new one from scratch.
  • Same type → update in place. React keeps the DOM node or component instance and updates only the changed props.
  • Lists are matched by key, so reordering items moves them instead of rewriting each one (see Rendering lists and keys).

The first two rules decide whether state survives:

Wrapper.jsx
export function Wrapper({ asSection, highlighted }) {
  if (asSection) {
    return (
      <section>
        <input aria-label="note" />
      </section>
    );
  }
  return (
    <div className={highlighted ? "highlight" : ""}>
      <input aria-label="note" />
    </div>
  );
}

// type "hello" into the input
// <Wrapper highlighted />            → same <input> node, value still "hello"
// <Wrapper asSection highlighted />  → a brand-new <input>, value ""
What’s happening
  1. The test renders Wrapper with both flags false and types hello into the input. The text lives in the DOM input itself (it's uncontrolled).
  2. Re-rendering with highlighted changes only the div's className. Same type (div) at the same position, so React updates the class attribute and keeps both nodes — the input is the same object and still says hello.
  3. Re-rendering with asSection returns a <section> where there used to be a <div>. Different type, so React unmounts the whole old subtree, including the input, and mounts a new section with a new, empty input.
  4. Nothing about the input changed in the JSX, yet it lost its value — because its parent changed type. The same rule applies to components: swap <UserCard /> for <AdminCard /> at the same spot and all state inside is reset.
  5. You'll use this on purpose too: giving a component a different key (<Profile key={userId} />) tells React it's a different item, so it remounts with fresh state.
AdvancedFiber, the render phase and the commit phase

Since React 16, the reconciler is called Fiber. Each component instance in the tree has a fiber object that remembers its type, props, state (hooks) and links to its parent, child and sibling. React keeps two trees of fibers: the current tree (what's on screen) and a work-in-progress tree being built for the next update.

An update has two phases:

  • Render phase — React calls your components and diffs the results, building the work-in-progress tree. It's pure computation with no DOM changes, so React can pause it, throw it away, or (in StrictMode) run it twice. Since React 18's concurrent rendering, an urgent update such as typing can interrupt a slow render in progress (see Concurrent rendering).
  • Commit phase — React applies the collected DOM changes in one synchronous pass, then runs ref callbacks, layout effects, and later regular effects. This phase can't be interrupted, so the user never sees a half-updated screen.

The React team itself rarely says "Virtual DOM" any more; the docs talk about rendering and committing. In interviews the term is still universal, so use it — then show you know it's an element tree diffed by Fiber.

#One-way data flow

In React, data flows in one direction: down the tree, from parent to child, through props. A child can read its props but can't change them. If a child needs the data to change, it calls a function the parent passed down, and the parent updates its own state. Then the new value flows down again on the next render.

From my notes, how it works:

  1. The parent holds the state — the data that needs to be shared.
  2. It passes that state down to children via props.
  3. The child uses the props to display the data but can't change them directly.
  4. To change the data, the child calls a function passed down from the parent (usually an event handler). That updates the parent's state, which re-renders the parent and its children with the new data.

A mental model: the parent is the single owner of a document, and children get read-only copies plus a "request a change" button. Because only the owner edits, there's exactly one place to look when the data is wrong.

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

export function Cart() {
  const [quantity, setQuantity] = useState(1); // the single source of truth

  return (
    <div>
      <QuantityPicker value={quantity} onChange={setQuantity} />
      <Summary quantity={quantity} price={250} />
    </div>
  );
}

function QuantityPicker({ value, onChange }) {
  return (
    <div>
      <button onClick={() => onChange(value - 1)} disabled={value === 1}>−</button>
      <span>{value}</span>
      <button onClick={() => onChange(value + 1)}>+</button>
    </div>
  );
}

function Summary({ quantity, price }) {
  return <p>Total: ₹{quantity * price}</p>;
}
What’s happening
  1. Cart owns quantity (starts at 1). It passes the value down to both children: QuantityPicker gets it as value, Summary as quantity. First render: the picker shows 1, the − button is disabled, and the summary shows Total: ₹250.
  2. QuantityPicker can't change value — it doesn't own it. Instead it received onChange (which is setQuantity) and calls it: clicking + runs onChange(1 + 1).
  3. That's a state update in Cart. React re-renders Cart, which now has quantity = 2, and both children get the new value as props: picker shows 2, summary shows Total: ₹500. Another + → 3 and ₹750 (the test checks this).
  4. Summary never talks to QuantityPicker. The siblings stay in sync because they both read from the same state in their common parent — data goes down, events go up.
  5. If the picker kept its own copy of the quantity in its own state, the two could disagree. Keeping one owner is what makes the data flow predictable (see Lifting state up).

Props really are read-only. In development React freezes the props object, so mutating it throws:

React
function Title(props) {
  props.text = props.text.toUpperCase(); // ❌ TypeError: Cannot assign to read only property 'text' of object '#<Object>'
  return <h1>{props.text}</h1>;
}

Compute a new value instead: const text = props.text.toUpperCase();. (The production build skips the freeze for speed, so the mutation would silently "work" there — another reason to never rely on it.)

Why it matters in real code:

  • Debugging: when the summary shows a wrong total, the bug is in Cart or in what it passes down — you never hunt for some distant component that quietly edited the data.
  • The cost is "prop drilling": passing a value through several layers that don't use it just to reach a deep child. Composition (passing components as children) and context solve that (see children and composition over inheritance and The Context API).

#Rendering an app: createRoot vs ReactDOM.render

A React app starts with one call that connects React to a DOM node. You pick an element from your index.html (conventionally <div id="root">), create a root on it, and render your top component into it. From then on React owns everything inside that node. Since React 18 that's createRoot; before that it was ReactDOM.render, which my notes still use (in the Redux example) and which no longer exists in React 19.

main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>
);
What’s happening
  1. createRoot comes from react-dom/client (not react-dom). It takes the container DOM node and returns a root object; nothing is rendered yet.
  2. root.render(<App />) renders the app into the container. React calls App, builds the element tree and creates the DOM inside #root, replacing whatever was in the container before.
  3. <StrictMode> wraps the app to turn on extra development-only checks (see StrictMode). It renders no DOM of its own.
  4. This file is the entry point that index.html loads (<script type="module" src="/src/main.jsx"> in a Vite project). It runs once; after that, all updates come from state changes inside components.

The root object has a small API, and calling render again on the same root updates the app instead of starting over:

React
const root = createRoot(container);
root.render(<Clicker label="Likes" />);   // click twice → "Likes: 2"
root.render(<Clicker label="Hearts" />);  // → "Hearts: 2" — same component, state kept, same <button> node
root.unmount();                           // container is empty again; effects cleaned up
What’s happening
  1. The first root.render mounts Clicker, whose internal counter starts at 0. Two clicks take it to 2, so the button reads Likes: 2.
  2. The second root.render passes a different prop but the same component type at the same position. Reconciliation keeps the instance, so the counter is still 2 and only the label changes: Hearts: 2. The <button> is the same DOM node (the test checks with toBe).
  3. root.unmount() removes everything React rendered (innerHTML becomes "") and runs every cleanup function, so timers and subscriptions stop. Use it when a React widget lives inside a non-React page and that part of the page goes away.

Old and new, side by side:

TaskReact 17 and earlierReact 18 / 19
MountReactDOM.render(<App />, el)createRoot(el).render(<App />)
Updatecall ReactDOM.render again with the same elroot.render(<App />) again
UnmountReactDOM.unmountComponentAtNode(el)root.unmount()
Hydrate server HTMLReactDOM.hydrate(<App />, el)hydrateRoot(el, <App />)
Import fromreact-domreact-dom/client

What changed across versions:

  • React 18 deprecated ReactDOM.render. It still worked but logged (wording from the React 18 upgrade guide; I couldn't run React 18 here) "ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17." That "behave as if it's React 17" part matters: under the legacy API you got none of React 18's concurrent features and none of its automatic batching (see State updates are batched).
  • React 19 removed render, hydrate, unmountComponentAtNode and findDOMNode from react-dom. In React 19.3 all four are undefined, so old code fails with TypeError: ReactDOM.render is not a function. The fix is the table above; React also ships a codemod (npx codemod@latest react/19/replace-reactdom-render).
AdvancedMore than one root

Most apps have exactly one root. You create several when React only controls islands of a page that's otherwise server-rendered or built with something else — a React widget inside a legacy jQuery or Angular page, or several micro-frontends that each mount into their own container. Each root is independent: separate state, separate updates, and you unmount each one yourself. Portals (rendering into a different DOM node from the same tree) are usually a better fit for modals; see Portals.

createRoot also takes options. The ones you'll meet are the React 19 error callbacks — onUncaughtError, onCaughtError and onRecoverableError — for sending errors to a logging service (see Root error callbacks in React 19), and identifierPrefix for useId when several roots share a page.

#StrictMode

Added

<StrictMode> is a component that turns on extra checks in development for everything inside it. It renders nothing visible and does nothing in production builds. Its job is to make certain kinds of bugs — impure components, effects without cleanup, use of deprecated APIs — show up immediately instead of months later in production.

It does that by deliberately doing things twice. React's rules say rendering must be pure (same props and state → same result, no side effects) and effects must clean up after themselves. If your code follows the rules, running it twice is harmless. If it doesn't, running it twice produces a visible symptom: a doubled log, a doubled request, a counter that jumps by two. A mental model: StrictMode is a crash-test dummy run — React rehearses mount, unmount and remount so the weak spots show before real users hit them.

Probe.jsx
import { useEffect, useMemo, useState } from "react";

export const log = [];

export function Probe() {
  log.push("render");
  const [items] = useState(() => {
    log.push("useState initializer");
    return ["a", "b"];
  });
  const total = useMemo(() => {
    log.push("useMemo");
    return items.length;
  }, [items]);

  useEffect(() => {
    log.push("effect setup");
    return () => log.push("effect cleanup");
  }, []);

  return <p>{total} items</p>;
}

// render(<Probe />)
// ["render", "useState initializer", "useMemo", "effect setup"]
//
// render(<StrictMode><Probe /></StrictMode>)
// ["render", "useState initializer", "useState initializer", "useMemo", "useMemo",
//  "render", "effect setup", "effect cleanup", "effect setup"]
What’s happening
  1. Without StrictMode each thing happens once: the component renders, the useState initializer and the useMemo calculation run on that first render, and after the DOM is committed the effect's setup runs.
  2. With StrictMode, the component function runs twice ("render" appears twice). Inside the first call, React also calls the useState initializer and the useMemo function twice each, to catch impure calculations. The second render reuses those first results — the React 19 behaviour where useMemo and useCallback keep the memoized value from the first render during the second.
  3. Only one of the two render results is used; the other is thrown away. If a render had a side effect (pushing to a global array, as log.push does here on purpose), you see it twice — that's exactly the signal StrictMode is designed to give.
  4. After the commit, React runs setup → cleanup → setup. It simulates the component being unmounted and immediately remounted with the same state. An effect that subscribes without unsubscribing ends up subscribed twice, which you'd notice straight away.
  5. On a real unmount the log gets a single "effect cleanup", as normal. None of this doubling happens in a production build, so StrictMode never slows down or changes your shipped app.

What StrictMode checks in React 19.3:

  • Double rendering of components, plus initializer functions passed to useState, useReducer and useMemo.
  • Double effects: every effect is set up, cleaned up and set up again on mount (added in React 18). React 19.3 does this during hydration of server-rendered HTML too, not just for client-rendered roots.
  • Double ref callbacks (React 19): a callback ref is attached, detached and re-attached on mount — the test logs ["attach INPUT", "detach", "attach INPUT"].
  • Warnings for deprecated APIs still around in class components, such as UNSAFE_componentWillMount.

Where to use it: wrap the whole app in main.jsx, as every Vite and Next.js template does. You can also wrap just part of a tree when adopting it gradually in an older codebase.

#Project setup: Vite vs Create React App

My notes have this heading ("Bundling and VITE vs create-react-app") but the content under it was a screenshot that didn't come through, so everything here is written fresh.

React code can't go to the browser as-is: JSX must be compiled, hundreds of imports must be resolved and bundled, and the result minified. A build tool does all that and also runs a development server with instant reload. For years the default was Create React App (CRA), a zero-config wrapper around webpack and Babel. Today it's Vite — CRA was officially deprecated on 14 February 2025, and the React docs now point to a framework (Next.js, React Router, Expo) or a build tool (Vite, Parcel, Rsbuild).

The mental model for why Vite won: CRA's webpack dev server bundled your entire app before showing anything, and re-bundled on every change, so startup grew with app size. Vite's dev server doesn't bundle your code at all in development — the browser asks for one module at a time over native ES modules, and Vite compiles just that file on request. Startup is near-instant whatever the app size.

Shell
# Vite (current)
npm create vite@latest my-app -- --template react      # or react-ts for TypeScript
cd my-app && npm install
npm run dev        # dev server on http://localhost:5173
npm run build      # production bundle in dist/

# Create React App (deprecated, 2016–2025)
npx create-react-app my-app
npm start          # dev server on http://localhost:3000
npm run build      # production bundle in build/

A Vite project's moving parts are small. index.html sits at the project root and loads the entry module directly:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { port: 5173 },
});
Shell
$ npx vite
  VITE v8.3.4  ready in 92 ms

  ➜  Local:   http://localhost:5179/
  ➜  Network: use --host to expose

$ npm run build
vite v8.3.4 building client environment for production...
transforming...
✓ 15 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                  0.31 kB │ gzip:  0.22 kB
dist/assets/index-Botmzbpb.js  219.70 kB │ gzip: 68.68 kB

✓ built in 69ms
What’s happening
  1. index.html is the real entry point, not a template hidden in public/ as in CRA. Its <script type="module" src="/src/main.jsx"> is a native ES module import, and main.jsx creates the root (see Rendering an app: createRoot vs ReactDOM.render).
  2. vite.config.js adds @vitejs/plugin-react, which compiles JSX with the automatic runtime and adds Fast Refresh (edit a component and it updates in place without losing state). I ran this exact setup with Vite 8.3.4; the dev server was ready in 92 ms (it printed port 5179 because I passed --port 5179; the default is 5173).
  3. In dev, the browser requests /src/App.jsx and Vite returns that one file compiled on the spot: imports rewritten to point at pre-bundled dependencies (/node_modules/.vite/deps/react.js), JSX turned into jsxDEV(...) calls, and Fast Refresh hooks ($RefreshSig$) added. Only the files you actually open get compiled.
  4. npm run build does the opposite: a full production bundle — tree-shaken, minified, with content-hashed file names (index-Botmzbpb.js) so browsers can cache them forever. Vite 8 uses Rolldown (a Rust bundler) for this; Vite 2–7 used Rollup, with esbuild for dev.
  5. The 219.70 kB (68.68 kB gzipped) is almost all react-dom; the app itself is a few lines. That's the baseline cost of a client-rendered React app.

Vite vs CRA in the places you'll notice:

Create React App (deprecated)Vite
Under the hoodwebpack + Babel, hidden in react-scriptsnative ESM dev server; Rolldown + Oxc in Vite 8
Dev startupbundles the whole app first; slows as the app growsnear-instant; compiles files on request
index.htmlpublic/index.html, injected by webpackproject root, it is the entry
Env variablesprocess.env.REACT_APP_*import.meta.env.VITE_*
Confignone, or npm run eject (one-way)vite.config.js, always editable
Dev port / output3000 / build/5173 / dist/
TestsJest preconfiguredadd Vitest (same config, Jest-compatible API)
AdvancedMigrating a CRA app to Vite

The usual steps, roughly in order:

  • Remove react-scripts; add vite and @vitejs/plugin-react; replace the scripts with "dev": "vite", "build": "vite build", "preview": "vite preview".
  • Move public/index.html to the root, delete %PUBLIC_URL%, and add <script type="module" src="/src/index.jsx"></script>.
  • Rename files containing JSX to .jsx/.tsx (Vite only compiles JSX in those extensions by default).
  • Rename env vars from REACT_APP_X to VITE_X and change process.env.REACT_APP_X to import.meta.env.VITE_X.
  • Move Jest to Vitest (mostly a find-and-replace from jest.fn to vi.fn), or keep Jest with its own Babel config.
  • SVG-as-component imports (import { ReactComponent as Logo } from './logo.svg') were a CRA feature; with Vite add a plugin such as vite-plugin-svgr.

When not to pick plain Vite: if you need server rendering, SEO for public pages, or file-based routing with data loading built in, start with a framework (Next.js, or React Router in framework mode — which itself runs on Vite).

#What a React project's package.json contains

My notes have a heading for this ("PACKAGE.json ( React projects looks like this )") but the example itself was a screenshot that didn't come through; here's a current one written from scratch.

package.json is the manifest of a JavaScript project: its name, the commands you run (npm run dev), and the packages it depends on with acceptable version ranges. npm reads it to install node_modules, and the exact versions it chose are recorded in package-lock.json. In an interview, being able to walk through one shows you know how a React app is actually put together.

package.json
{
  "name": "my-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "test": "vitest"
  },
  "dependencies": {
    "react": "^19.3.0",
    "react-dom": "^19.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^6.1.2",
    "eslint": "^10.12.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "vite": "^8.3.4",
    "vitest": "^5.0.3"
  }
}
What’s happening
  1. "name" and "version" identify the package; "private": true stops it being published to npm by accident — right for an app, which is never meant to be installed by others.
  2. "type": "module" makes .js files in the project ES modules, so config files like vite.config.js can use import/export.
  3. "scripts" are the project's commands: npm run dev runs vite, npm run build runs vite build, and so on. npm adds node_modules/.bin to the PATH while running them, which is why vite works without a global install. (I used the dev/build/preview scripts above for the Vite build shown in the previous topic.)
  4. "dependencies" are what the app needs at runtime: react (components, hooks — platform-independent) and react-dom (the browser renderer: createRoot, portals). They're two packages because React also renders to native views, PDFs, terminals and more with other renderers.
  5. "devDependencies" are tools used to build, lint and test: Vite and its React plugin, ESLint with the hooks rules, Vitest. They aren't part of what ships.
  6. ^19.3.0 is a caret range: any 19.x.y at or above 19.3.0, but not 20.0.0. npm install resolves it to a concrete version and writes it to package-lock.json; npm ci installs exactly what the lock file says, which is what CI and Docker builds should use.
Advanceddependencies vs devDependencies in an app vs a library

For a bundled app, the split is mostly documentation: the bundler includes whatever your code imports, wherever it's listed, and npm install installs both groups. It still matters for npm install --omit=dev in server images and for clarity.

For a library (a component package others install), it matters a lot:

  • react and react-dom go in peerDependencies ("react": "^18.0.0 || ^19.0.0"), not dependencies. The app supplies React; if the library brought its own copy, the app would end up with two Reacts, and hooks fail with "Invalid hook call" because the library's components would be calling a different React than the one rendering them.
  • Build tools go in devDependencies.
  • "exports", "main", "module" and "types" tell bundlers which files to load, and "sideEffects": false lets them tree-shake unused components (see Building a reusable component library).

#React versions at a glance: 16 to 19.3

Added

Interviewers like "what changed in React 18?" and "what's new in 19?", and real codebases are spread across all of these versions. The short story: 16 rebuilt the engine (Fiber) and 16.8 added hooks; 17 added nothing visible but made upgrades possible; 18 made rendering concurrent; 19 added Actions and cleaned out a decade of deprecated APIs; 19.1–19.3 are feature releases on top.

VersionReleasedHeadline changesWhat it means for you
16.0Sept 2017Fiber reconciler; error boundaries; portals; components can return arrays and stringsThe engine everything since is built on
16.3Mar 2018New Context API (createContext), createRef, forwardRef, getDerivedStateFromProps; componentWillMount etc. renamed UNSAFE_The old legacy context and string refs start their slow deprecation
16.6Oct 2018React.memo, React.lazy + <Suspense> for code splitting, static contextTypeLazy-loaded routes become built-in
16.8Feb 2019Hooks: useState, useEffect, useContext, useReducer, useRef…Function components can do everything; class components become legacy
17.0Oct 2020"No new features": events delegated to the root container instead of document; event pooling removed; new JSX transform (no import React needed)Several React versions can coexist on one page; upgrades get easier
18.0Mar 2022createRoot and concurrent rendering; automatic batching everywhere; useTransition, useDeferredValue, useId, useSyncExternalStore; streaming SSR; Strict Mode double effects; IE droppedYou must switch to createRoot to get any of it
18.3Apr 2024Same as 18.2 plus warnings for everything React 19 removesThe "upgrade to this first" release
19.0Dec 2024Actions: useActionState, useOptimistic, useFormStatus, form action; use(); ref as a prop; ref cleanup functions; <Context> as provider; document metadata; removed ReactDOM.render, propTypes checks, function-component defaultProps, string refs, legacy contextBig upgrade; old patterns in this guide stop working
19.1Mar 2025Owner Stacks (captureOwnerStack, dev-only); Suspense fixes; useId format changedBetter debugging
19.2Oct 2025<Activity>, useEffectEvent, cacheSignal, React Performance tracks in browser DevTools; partial pre-renderingHide UI without losing state; cleaner effects
19.3Sept 2026<ViewTransition> and addTransitionType (stable), Fragment refs (stable), browser() in react-dom, Trusted Types; transitions render independentlyAnimated transitions built in

You can check what's installed with import { version } from "react" — in this guide's sandbox it's "19.3.0".

Which version?

Your codebase has import React from 'react' at the top of every file, uses ReactDOM.render, and mixes class components with some hooks. What's the oldest and newest React it could be running?

Show answer

At least 16.8, and at most 18.x.

What’s happening
  1. Hooks exist, so it's 16.8 (February 2019) or later.
  2. ReactDOM.render was removed in 19.0, so it's below 19. On 18.x it would run in legacy mode with the "ReactDOM.render is no longer supported in React 18" warning in the console — a good thing to check first.
  3. import React from 'react' everywhere means it was written for (or configured with) the classic JSX transform. That's a hint it started before React 17 or CRA 4, but not proof: the import is harmless with the new transform too.
  4. An upgrade path: move to 18.3 and fix its warnings, switch to createRoot, then upgrade to 19 with the official codemods. Class components still work in 19, so they don't block the upgrade.

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.