strictmode
170 questions · 16 sections · React 19 · TypeScript 5

Interview prep

The TypeScript and React questions that come up most, each with the answer to give, a worked example and the follow-up that tends to come next. Read the question, answer it out loud, then open it to check.

TypeScript

TypeScript fundamentals

The warm-up round. Almost every TypeScript interview opens here, and a vague answer to any of these sets the tone for the rest.

15
01What is TypeScript, and what does it actually give you over JavaScript?Junior
SHORT ANSWER

TypeScript is JavaScript plus a static type system that is checked at compile time and then erased. It catches a whole class of bugs before the code runs, and it is what makes editor tooling (autocomplete, safe renames, jump to definition) trustworthy on a large codebase.

  • A superset. Every valid JavaScript program is syntactically valid TypeScript, so a codebase can adopt it file by file.
  • Compile-time only. tsc (or Babel, SWC, esbuild) strips the types and emits plain JavaScript. Nothing about your types exists at runtime.
  • Structural. Two types are compatible when their shapes match, not when their names match.
  • Inferred. You annotate the boundaries (parameters, public return types) and the compiler works out the rest.
function total(price: number, quantity: number) {
  return price * quantity;
}

total(9.99, 3); // ok
total("9.99", 3); // error: string is not assignable to number
total(9.99); // error: expected 2 arguments, but got 1

Beyond catching mistakes, the types are documentation that cannot go stale, and they make refactoring mechanical: change a function signature and the compiler lists every call site you broke.

The trap: TypeScript does not validate data at runtime. A fetch response typed as User is a claim, not a check. If the server sends something else, the bug surfaces far from where the lie was told.

FOLLOW-UPS
Does TypeScript make the code faster?
No. Types are erased, so the emitted JavaScript runs exactly as hand-written JavaScript would. The win is in correctness and maintenance, not performance.
What does it cost?
A build step, compile time on large projects, a learning curve for the advanced type features, and the temptation to trust types at boundaries where they were only asserted (any, as, untyped JSON).
typescriptlang.org
02What is the difference between any, unknown, never and void?Junior
SHORT ANSWER

any switches the type checker off for a value. unknown is the safe version: it accepts anything, but you must narrow it before using it. never is the type with no values, for code that cannot be reached. void means a function's return value is not meant to be used.

TypeWhat you can assign to itWhat you can do with it
anyAnythingAnything. No checking, and it spreads to whatever it touches.
unknownAnythingNothing, until you narrow it with a check.
neverNothingIt is assignable to every type; it marks impossible states.
voidundefinedSignals that a return value should be ignored.
declare const input: unknown;

input.toUpperCase(); // error: 'input' is of type 'unknown'

if (typeof input === "string") {
  input.toUpperCase(); // ok: narrowed to string
}

const anything: any = input;
anything.foo.bar(); // compiles, then throws at runtime

function fail(message: string): never {
  throw new Error(message);
}

Reach for unknown wherever a value arrives from outside the type system: JSON.parse, a catch clause, postMessage, a third-party callback. It forces the check that any lets you skip.

The trap: any is contagious. One any return type silently turns every value derived from it into any as well, and the compiler stops reporting anything along that path.

FOLLOW-UPS
Where does never show up in real code?
As the return type of functions that always throw or loop forever, as the type left over once a union has been fully narrowed (which powers exhaustiveness checks), and as the way to drop members inside conditional types.
What is the difference between void and undefined?
A function typed to return undefined must return exactly that. A callback typed () => void may return anything, and the caller promises to ignore it. That is why items.forEach((item) => list.push(item)) type-checks even though push returns a number.
What is the type of the variable in a catch clause?
unknown under strict (via useUnknownInCatchVariables), because anything can be thrown. Narrow with instanceof Error before reading .message.
typescriptlang.org
03interface or type: what is the difference, and when do you use each?Junior
SHORT ANSWER

For object shapes they are nearly interchangeable. An interface can be extended and reopened (declaration merging); a type alias can name anything, including unions, tuples and mapped or conditional types, but cannot be reopened. Pick one convention for objects and use type whenever you need something an interface cannot express.

interface User {
  id: string;
  name: string;
}
interface Admin extends User {
  permissions: string[];
}

type AdminAlias = User & { permissions: string[] }; // intersection instead of extends
type Id = string | number; // only an alias can name a union
type Point = [x: number, y: number]; // ...or a tuple
type Getter<T> = () => T; // ...or a function type

interface Box { width: number }
interface Box { height: number }
const box: Box = { width: 1, height: 2 }; // ok: the two declarations merged

type Size = { width: number };
// type Size = { height: number }; // error: Duplicate identifier 'Size'
interfacetype
Object shapesYesYes
Unions, tuples, primitivesNoYes
Mapped and conditional typesNoYes
Extendingextends (conflicts are reported)& (conflicts quietly become never)
Declaration mergingYesNo
implements on a classYesYes, for object types

Interview line: I default to interface for public object shapes that others may extend, and type for unions, function signatures and anything computed. What matters most is that the team is consistent.

FOLLOW-UPS
When is declaration merging actually useful?
Augmenting types you do not own: adding a property to Window, extending a library's Theme or session type, or adding fields to Express's Request.
Is there a performance difference?
On very large codebases, yes, slightly. interface extends relationships are cached by name, while big intersections are re-evaluated structurally. It only matters at scale.
typescriptlang.org
04How does type inference work, and what does as const do?Mid
SHORT ANSWER

TypeScript infers a type from the initializer. A let or a mutable property is widened to the general type (string), while a const keeps the literal ("dark"). as const asks for the narrowest possible type everywhere: literals stay literals, objects become readonly, arrays become readonly tuples.

let mode = "dark"; // string: a let can be reassigned, so the literal widens
const theme = "dark"; // "dark": a const cannot change, so the literal is kept

const config = { env: "prod", retries: 3 };
// { env: string; retries: number } because properties are mutable

const frozen = { env: "prod", retries: 3 } as const;
// { readonly env: "prod"; readonly retries: 3 }

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

The classic bug it fixes is a literal that widened before it reached a function expecting a union:

declare function request(url: string, method: "GET" | "POST"): void;

const req = { url: "/users", method: "GET" };
request(req.url, req.method); // error: string is not assignable to "GET" | "POST"

const fixed = { url: "/users", method: "GET" } as const;
request(fixed.url, fixed.method); // ok

The trap: as const is compile-time only. It does not freeze anything: the object can still be mutated by untyped code at runtime. Object.freeze is the runtime tool, and it is shallow.

FOLLOW-UPS
When should you write an annotation instead of relying on inference?
At boundaries: function parameters always, and return types of exported functions so a change in the body cannot silently change the public contract. Locals are best left to inference.
What is a const type parameter?
Since TypeScript 5.0, function f<const T>(value: T) infers T as if the caller had written as const, so f(["a", "b"]) gives readonly ["a", "b"] instead of string[].
typescriptlang.org
05What are union and intersection types?Junior
SHORT ANSWER

A union A | B is a value that is one or the other, so you may only use what both have in common until you narrow. An intersection A & B is a value that is both at once, so it has every member of each.

type Id = string | number;

function format(id: Id) {
  id.toFixed(); // error: 'toFixed' does not exist on type 'string'
  return typeof id === "number" ? id.toFixed(0) : id.trim();
}

type Timestamps = { createdAt: Date; updatedAt: Date };
type Post = { title: string } & Timestamps; // has all three properties

type Impossible = string & number; // never: no value is both

The naming feels backwards for objects until you think in sets of values: a union admits more values, so you know less about any one of them; an intersection admits fewer values, so you know more.

The trap: Intersecting object types with a conflicting property does not error. The property becomes never and the type is unusable. interface extends reports the conflict instead.

FOLLOW-UPS
How do you safely use a union of object types?
Give every member a shared literal property (a discriminant such as kind or status) and switch on it. That is a discriminated union, and it is the single most useful pattern in application TypeScript.
typescriptlang.org
06TypeScript is structurally typed. What does that mean, and what are excess property checks?Mid
SHORT ANSWER

Compatibility is decided by shape: if a value has at least the required members with compatible types, it is assignable, whatever its declared name. The one exception is a fresh object literal, where extra properties are reported because they are almost always typos.

interface Point {
  x: number;
  y: number;
}

class Vector {
  constructor(public x: number, public y: number, public z: number) {}
}

const fromClass: Point = new Vector(1, 2, 3); // ok: it has x and y, which is all Point asks for

const literal: Point = { x: 1, y: 2, z: 3 }; // error: 'z' does not exist in type 'Point'

const wide = { x: 1, y: 2, z: 3 };
const viaVariable: Point = wide; // ok: the excess property check only applies to fresh literals

Languages like Java and C# are nominal: a Vector is a Point only if it says so. Structural typing fits JavaScript, where objects are routinely built as literals and passed around without classes.

FOLLOW-UPS
How do you get nominal behaviour when you need it, say to stop a UserId being passed as an OrderId?
Brand the type: intersect the primitive with a phantom property that only a constructor function can add. The brand exists only at the type level; at runtime the value is still a plain string.
Why are the excess property checks limited to literals?
Because a literal has no other use, an unknown key is certainly a mistake. A variable may legitimately carry more than this consumer needs, and rejecting it would break ordinary subtyping.
typescriptlang.org
07How would you stop two IDs that are both strings from being mixed up?Senior
SHORT ANSWER

With branded (opaque) types: intersect the primitive with a unique phantom property, and only hand out the brand through a constructor or validator. Structurally identical strings then become mutually unassignable.

type Brand<T, Name extends string> = T & { readonly __brand: Name };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const toUserId = (value: string) => value as UserId; // the single place the assertion lives

declare function loadUser(id: UserId): void;
declare const orderId: OrderId;

loadUser(toUserId("u_1")); // ok
loadUser(orderId); // error: 'OrderId' is not assignable to 'UserId'
loadUser("u_1"); // error: 'string' is not assignable to 'UserId'

The same trick encodes validation in the type: type Email = Brand<string, "Email"> can only be produced by a function that actually checked the format, so any function accepting Email knows the check already happened.

typescriptlang.org
08How does TypeScript handle null and undefined? What do ?., ?? and ! do?Junior
SHORT ANSWER

With strictNullChecks on, null and undefined are their own types and must be handled before use. ?. short-circuits to undefined when the left side is nullish, ?? supplies a fallback only for null or undefined, and the postfix ! tells the compiler to trust you, with no runtime check at all.

interface User {
  name: string;
  address?: { city: string }; // optional: { city: string } | undefined
}

function describe(user: User | null) {
  user.name; // error: 'user' is possibly 'null'

  const city = user?.address?.city; // string | undefined
  const label = user?.address?.city ?? "Unknown"; // string
  const forced = user!.name; // compiles; throws at runtime if user is null
  return [city, label, forced];
}

declare const stored: number | undefined; // imagine it holds 0
const a = stored || 10; // 10: || treats every falsy value as missing
const b = stored ?? 10; // 0: ?? only replaces null and undefined

The trap: ! is an assertion, not a guard. Every ! is a place where the compiler stopped helping, so treat it like as: fine when you can explain why it holds, a smell when it is there to make an error go away.

FOLLOW-UPS
Is name?: string the same as name: string | undefined?
Not quite. The optional form lets you omit the key entirely; the union form requires the key to be present, even if its value is undefined. With exactOptionalPropertyTypes the optional form also refuses an explicit undefined.
What happens with strictNullChecks off?
null and undefined become assignable to every type, so string really means string | null | undefined and the compiler cannot warn about the most common runtime error in JavaScript.
typescriptlang.org
09What is the difference between a type annotation, a type assertion (as) and satisfies?Mid
SHORT ANSWER

An annotation checks the value and then widens it to the annotated type. An assertion overrides the compiler without checking. satisfies checks the value against a type but keeps the value's own, more precise inferred type.

type Theme = Record<string, string | [number, number, number]>;

const annotated: Theme = { primary: "#e4572e", ink: [18, 16, 15] };
annotated.primary.toUpperCase(); // error: widened to string | [number, number, number]

const checked = { primary: "#e4572e", ink: [18, 16, 15] } satisfies Theme;
checked.primary.toUpperCase(); // ok: validated against Theme, still known to be a string
checked.missing; // error: unknown keys are rejected, unlike the annotated version

const asserted = {} as Theme; // compiles and checks nothing
  • Annotation (const x: T = ...): use when you want the variable to have exactly type T.
  • satisfies: use for config objects, route tables and lookup maps, where you want validation plus precise keys and literal values.
  • Assertion (as T): use when you know something the compiler cannot, for example document.getElementById("email") as HTMLInputElement.

The trap: An assertion is only allowed between types that overlap, which is why you sometimes see value as unknown as T. That double assertion compiles for any two types and is the loudest escape hatch in the language.

FOLLOW-UPS
Why can you not use the <T>value assertion syntax in a .tsx file?
It is ambiguous with JSX, so only value as T is allowed there.
Can you combine them?
Yes: { ... } as const satisfies Config gives literal, readonly types and validation in one expression.
typescriptlang.org
10What is a tuple, and how is readonly different from const?Junior
SHORT ANSWER

A tuple is an array type with a fixed length and a known type at each position. const stops a variable from being reassigned at runtime; readonly stops a property or array from being mutated through that reference at compile time. They solve different problems and are often used together.

const pair: [string, number] = ["age", 42];
const [label, value] = pair; // label: string, value: number

type Range = [start: number, end?: number]; // labelled, with an optional element
type Args = [first: string, ...rest: number[]]; // a variadic tail

function useToggle(initial = false) {
  let on = initial;
  const toggle = () => {
    on = !on;
  };
  return [on, toggle] as const; // readonly [boolean, () => void]
}

const frozen: readonly number[] = [1, 2, 3];
frozen.push(4); // error: 'push' does not exist on type 'readonly number[]'

const items = [1, 2, 3]; // const only fixes the binding
items.push(4); // ok: the array itself is still mutable

Without as const, the hook above would return (boolean | (() => void))[], and every caller would have to narrow each element. This is why custom hooks that return a pair almost always end in as const.

FOLLOW-UPS
Is readonly deep?
No. readonly items: Item[] stops you reassigning items, not pushing to it. Use readonly Item[] or ReadonlyArray<Item> for the array, and a recursive DeepReadonly type if you need it all the way down.
Can a mutable array be passed where a readonly one is expected?
Yes, a mutable array is assignable to a readonly one. The reverse is an error, which is why accepting readonly T[] in function parameters is the more flexible signature.
typescriptlang.org
11How do you type functions: optional, default and rest parameters, and overloads?Mid
SHORT ANSWER

Parameters are annotated individually; ? makes one optional, a default value makes it optional and infers its type, and a rest parameter is typed as an array or tuple. Overloads list several call signatures over one implementation, for when the return type depends on which arguments were passed.

function greet(name: string, greeting = "Hello", ...titles: string[]): string {
  return greeting + ", " + [...titles, name].join(" ");
}

type Comparator<T> = (a: T, b: T) => number; // a function type, reusable as a callback type
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
  return Array.isArray(input) ? input.map(Number) : Number(input);
}

const one = parse("42"); // number
const many = parse(["1", "2"]); // number[]

declare const either: string | string[];
parse(either); // error: no overload matches; the implementation signature is not callable
  • Callers only see the overload signatures. The implementation signature must be compatible with all of them but is not itself a valid call.
  • Overloads are resolved top to bottom, so list the most specific one first.
  • Prefer a union parameter or a generic when the return type does not change. Overloads earn their place only when different inputs produce different output types.
FOLLOW-UPS
Two overloads take (a: string) and (a: string, ...rest: number[]). What must the implementation look like?
It must accept every call the overloads allow, so (a: string, ...rest: number[]) works: a rest parameter is already optional, and is simply empty for the first overload. There is no need to loosen it to any[].
How do you type this inside a function?
With a fake first parameter: function onClick(this: HTMLButtonElement, event: MouseEvent). It is erased from the output and only tells the checker what this will be.
typescriptlang.org
12What do public, private, protected and readonly do on a class? How is private different from #private?Junior
SHORT ANSWER

They control who may touch a member: public (the default) anyone, protected the class and its subclasses, private only the class. readonly allows assignment only in the declaration or constructor. TypeScript's private is enforced by the type checker and erased; JavaScript's #field is enforced by the engine at runtime.

abstract class Shape {
  // A parameter property: declares the field and assigns it in one step.
  constructor(protected readonly name: string) {}

  abstract area(): number; // subclasses must implement this

  describe() {
    return this.name + " with area " + this.area().toFixed(2);
  }
}

class Circle extends Shape {
  #radius: number; // private at runtime
  private scale = 1; // private only to the type checker

  constructor(radius: number) {
    super("circle");
    this.#radius = radius;
  }

  area() {
    return Math.PI * this.#radius ** 2 * this.scale;
  }
}

const circle = new Circle(2);
circle.describe(); // ok
circle.name; // error: 'name' is protected
circle.scale; // error: 'scale' is private
new Shape("blob"); // error: cannot create an instance of an abstract class

The trap: The two bugs interviewers plant in class snippets: reading a field without this. inside a method, and assigning this.make = make in a constructor without declaring make on the class. Both are compile errors, and both are easy to read past.

FOLLOW-UPS
Abstract class or interface?
An interface is a pure contract, erased at compile time, and a class can implement many. An abstract class exists at runtime and can carry state and shared implementation, but a class can extend only one. Use an interface unless you need to share code.
What does implements actually do?
It only checks that the class satisfies the interface. It does not add members or change the class's type, so you still have to declare and type every property yourself.
What is strictPropertyInitialization?
Part of strict: every declared property must be assigned in its declaration or in the constructor, otherwise its type would be lying about never being undefined.
typescriptlang.org
13What is the difference between object, {} and Record<string, unknown>? And how do index signatures work?Mid
SHORT ANSWER

object is any non-primitive. {} is anything except null and undefined, which includes numbers and strings. Record<string, unknown> is the one you usually want for 'some object with string keys': you can read any key, and each value must be narrowed before use.

let a: object; // objects, arrays, functions
let b: {}; // anything but null and undefined, yes, including 42
let c: Record<string, unknown>;

a = [1, 2];
a = 42; // error: number is not assignable to object
b = 42; // ok, which is rarely what you meant
b = null; // error
c = { id: 1 };
c.id.toFixed(); // error: 'c.id' is of type 'unknown'

An index signature says 'any key of this type maps to this value type', which is convenient and slightly dishonest:

const prices: Record<string, number> = { apple: 1.2 };
const pear = prices.pear; // typed as number, actually undefined

// Honest alternatives:
const known: Record<"apple" | "pear", number> = { apple: 1.2, pear: 0.9 }; // exact keys
const sparse: Partial<Record<string, number>> = { apple: 1.2 }; // values may be missing
const lookup = new Map<string, number>(); // .get() returns number | undefined

A favourite exercise is typing a merge function. Parameters typed as object do compile, spread included, but the return type is object, on which you can access nothing. Generics keep the shape:

function merge<A extends object, B extends object>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const merged = merge({ name: "Ada" }, { age: 36 });
merged.name; // string
merged.age; // number
FOLLOW-UPS
What does noUncheckedIndexedAccess change?
Reading through an index signature or an array index yields T | undefined, so prices.pear and items[0] must be checked before use. It is not part of strict, and it is worth turning on.
When do you use a Map instead of an object?
When keys are dynamic or not strings, when you need insertion order with frequent adds and deletes, or when you want .get() to be honest about missing entries.
typescriptlang.org
14Enums, unions of string literals, or an as const object: which do you use?Mid
SHORT ANSWER

A union of string literals is the default: it costs nothing at runtime and narrows perfectly. An as const object adds a runtime value you can iterate over while still deriving the union from it. Enums work, but they are one of the few TypeScript features that emit code, and string enums are nominal, which surprises people.

enum Direction { Up, Down } // numeric: Up = 0, Down = 1, plus a reverse mapping at runtime
enum Status { Active = "ACTIVE", Archived = "ARCHIVED" } // string enum

type StatusUnion = "active" | "archived"; // no runtime code at all

const STATUS = { Active: "active", Archived: "archived" } as const;
type StatusValue = (typeof STATUS)[keyof typeof STATUS]; // "active" | "archived"

declare function setStatus(status: Status): void;
setStatus(Status.Active); // ok
setStatus("ACTIVE"); // error: the raw string is not assignable to a string enum
Runtime outputIterableAccepts the raw literal
enumAn object (numeric enums add a reverse map)YesNo
Union of literalsNoneNoYes
as const objectThe object you wroteYesYes

The trap: A numeric enum parameter accepts any number variable, so setDirection(someNumber) compiles even when the value is 99. String enums and literal unions do not have this hole.

FOLLOW-UPS
What is a const enum?
An enum whose members are inlined at each use site, leaving no object behind. Inlining needs whole-program knowledge, which single-file transpilers (Babel, esbuild, SWC) do not have, so isolatedModules restricts it and most teams avoid it.
Why do some teams ban enums outright?
They are not erasable syntax, so they do not work with type-stripping runtimes (Node's built-in TypeScript support, erasableSyntaxOnly), and a union or as const object does the same job with plain JavaScript semantics.
typescriptlang.org
15Types are erased at runtime. How do you make data from an API trustworthy?Mid
SHORT ANSWER

Treat everything that crosses a boundary as unknown and validate it once, at the edge: with a type guard for small shapes, or a schema library such as Zod or Valibot, from which the static type is derived. Inside the boundary the types can then be trusted.

interface User {
  id: string;
  name: string;
}

async function unsafe(): Promise<User> {
  const res = await fetch("/api/user");
  return res.json(); // json() is Promise<any>: this compiles whatever the server sends
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof value.id === "string" &&
    "name" in value &&
    typeof value.name === "string"
  );
}

async function safe(): Promise<User> {
  const data: unknown = await (await fetch("/api/user")).json();
  if (!isUser(data)) throw new Error("Unexpected response shape");
  return data; // narrowed to User by the guard
}

Hand-written guards do not scale past a few fields. A schema gives you the validator and the type from one definition:

With a schema library
import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email().optional(),
});

type User = z.infer<typeof UserSchema>; // derived, never written twice

export async function loadUser(): Promise<User> {
  const res = await fetch("/api/user");
  return UserSchema.parse(await res.json()); // throws with a precise message on a bad payload
}

Interview line: Boundaries I validate: HTTP responses, form input, URL params, localStorage, postMessage, environment variables. Everything else I let the compiler handle.

FOLLOW-UPS
Why not just write as User?
It moves the failure. Instead of an error at the boundary saying the payload was wrong, you get undefined is not a function three components away, with a type that swears the property exists.
How do you keep the client types in sync with the server?
Generate them from the source of truth (OpenAPI, GraphQL codegen) or share them end to end (tRPC, a shared schema package). Hand-copied interfaces drift.
typescriptlang.org

Want reps on this? Drill TypeScript Types & Config →

TypeScript

Narrowing and unions

How the compiler follows your `if` statements. Discriminated unions and exhaustiveness checks are the part of TypeScript you use every day, so expect to write one on the spot.

6
01What is type narrowing, and what are the ways to narrow a type?Junior
SHORT ANSWER

Narrowing is the compiler refining a broad type to a more specific one by following your runtime checks. Inside an if (typeof x === "string") block, x is a string. It works with typeof, equality, truthiness, in, instanceof, Array.isArray, discriminant properties and custom type guards.

function describe(value: string | number | string[] | Date | null) {
  if (value === null) return "nothing"; // equality
  if (typeof value === "string") return value.toUpperCase(); // typeof
  if (typeof value === "number") return value.toFixed(1);
  if (Array.isArray(value)) return value.join(", "); // built-in type guard
  return value.toISOString(); // only Date is left
}

type Fish = { swim(): void };
type Bird = { fly(): void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) animal.swim(); // the in operator
  else animal.fly();
}

function message(error: unknown) {
  return error instanceof Error ? error.message : String(error); // instanceof
}

This is control flow analysis: the compiler tracks the type of each variable through branches, early returns, assignments and throw. You rarely annotate anything; you write the checks you would write in JavaScript anyway, and the types follow.

The trap: Truthiness narrowing removes null and undefined, but it also filters out 0, "" and NaN. if (!count) return is a bug when zero is a valid count. Compare against undefined or null explicitly.

FOLLOW-UPS
Why does typeof value === "object" not rule out null?
Because typeof null is "object", a quirk JavaScript has had since its first version. The compiler knows, and keeps null in the narrowed type until you check for it.
What does in narrow when the value is unknown?
After typeof value === "object" && value !== null && "id" in value, the type becomes object & Record<"id", unknown>, so you can read value.id and narrow that in turn. That is how hand-written validators are built.
typescriptlang.org
02What is a user-defined type guard, and how is it different from a function that returns boolean?Mid
SHORT ANSWER

A type guard is a function whose return type is a type predicate, value is T. When it returns true, the compiler narrows the argument to T at the call site. A plain boolean return carries no type information, so nothing is narrowed.

interface Cat {
  meow(): void;
}
interface Dog {
  bark(): void;
}

function looksLikeCat(pet: Cat | Dog): boolean {
  return "meow" in pet;
}

function isCat(pet: Cat | Dog): pet is Cat {
  return "meow" in pet;
}

function speak(pet: Cat | Dog) {
  if (looksLikeCat(pet)) {
    pet.meow(); // error: a boolean says nothing about the type of pet
  }
  if (isCat(pet)) {
    pet.meow(); // ok: narrowed to Cat
  } else {
    pet.bark(); // ok: narrowed to Dog
  }
}

An assertion function is the same idea for code that throws instead of branching:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") throw new TypeError("Expected a string");
}

function shout(input: unknown) {
  assertIsString(input);
  return input.toUpperCase(); // narrowed for the rest of the scope
}

const mixed = [1, null, 2, undefined];
const numbers = mixed.filter((n): n is number => n != null); // number[]

The trap: The compiler trusts a predicate; it does not verify it. function isUser(v: unknown): v is User { return true; } compiles, and every caller now holds a lie. A guard's body deserves the same care as a type assertion.

FOLLOW-UPS
Do you always have to write the predicate by hand?
Not since TypeScript 5.5: a function with no return annotation whose body is a simple narrowing expression gets an inferred predicate, so mixed.filter((n) => n != null) is number[] on its own. An explicit : boolean annotation switches that inference off, and filter(Boolean) still does not narrow.
What are the rules for the predicate?
It must name one of the function's parameters (or this), and the asserted type must be assignable to that parameter's declared type.
typescriptlang.org
03What is a discriminated union, and how do you make sure a switch handles every case?Mid
SHORT ANSWER

A discriminated union is a union of object types that share one literal property, the discriminant. Checking that property narrows to a single member. For exhaustiveness, assign the value to never in the default branch: if a case is missing, the leftover type is not never and the code stops compiling.

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: RequestState<string[]>): string {
  switch (state.status) {
    case "idle":
      return "Nothing yet";
    case "loading":
      return "Loading";
    case "success":
      return state.data.join(", "); // data exists only on this member
    case "error":
      return state.error.message;
    default: {
      const unreachable: never = state; // stops compiling the day a status is added
      return unreachable;
    }
  }
}

Here is what the check catches. A triangle was added to the union and one function was not updated:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "triangle"; base: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + JSON.stringify(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    default:
      return assertNever(shape); // error: the triangle member is not assignable to never
  }
}

Interview line: I model state as a union of the states that can actually occur, not as one object with optional fields. { loading: boolean; data?: T; error?: Error } allows loading with data and an error at once; the union makes that state unrepresentable.

FOLLOW-UPS
What can be a discriminant?
Any property whose type is a literal in every member: string and number literals, true/false, null/undefined. Strings are the convention because they read well in a switch.
Does narrowing survive destructuring?
Partly. When every member has the same property names, const { kind, payload } = action followed by a check on kind narrows payload too (since 4.6), provided both are const bindings. If in doubt, switch on action.kind directly.
typescriptlang.org
04What is the return type of a function that always throws, and why does it matter?Mid
SHORT ANSWER

never: the function has no reachable end point. It matters because the compiler treats a call to a never-returning function as the end of that code path, so narrowing continues after it exactly as it would after a throw or return.

function fail(message: string): never {
  throw new Error(message);
}

function parsePort(value: string | undefined): number {
  if (value === undefined) fail("PORT is required");
  return Number(value.trim()); // value is string here: fail() cannot return
}

A detail that catches people out: the inferred return type depends on how the function is written. An arrow or function expression that only throws is inferred as never, but a function declaration is inferred as void, so annotate it.

function failDeclared(message: string) {
  throw new Error(message);
}
type A = ReturnType<typeof failDeclared>; // void

const failArrow = (message: string) => {
  throw new Error(message);
};
type B = ReturnType<typeof failArrow>; // never
  • void: the function returns, but its value should be ignored.
  • never: the function does not return at all.
  • never is also what remains when a union is narrowed to nothing, which is the mechanism behind exhaustiveness checks.
FOLLOW-UPS
For the narrowing to work, does fail need anything special?
Yes: an explicit never return annotation, and it must be called as a statement. The compiler only does this analysis for functions whose type is declared, not inferred.
typescriptlang.org
05Why does Object.keys(obj) return string[] instead of (keyof T)[]?Mid
SHORT ANSWER

Because types are structural and open: a value of type T is guaranteed to have T's properties, but nothing stops it having more. Object.keys returns every key present at runtime, so promising keyof T would be unsound.

const user = { name: "Ada", age: 36 };

for (const key of Object.keys(user)) {
  user[key]; // error: a string cannot index { name: string; age: number }
}

// When you own the object and know it has no extra keys, say so once:
const keys = Object.keys(user) as (keyof typeof user)[];
keys.forEach((key) => user[key]); // ok

// Or keep the keys you care about as the source of truth:
const FIELDS = ["name", "age"] as const;
FIELDS.forEach((key) => user[key]); // ok, no assertion

The unsoundness is easy to demonstrate, which is why the standard library refuses to hide it:

interface Point {
  x: number;
  y: number;
}

function sum(point: Point) {
  let total = 0;
  for (const key of Object.keys(point) as (keyof Point)[]) {
    total += point[key]; // typed as number...
  }
  return total;
}

const labelled = { x: 1, y: 2, label: "origin" };
console.log(sum(labelled)); // compiles: extra properties are allowed on a variable
stdout
3origin
FOLLOW-UPS
Same question for for...in?
Same answer: the loop variable is string, for the same reason, and it also walks inherited enumerable properties.
typescriptlang.org
06Where does narrowing break down?Senior
SHORT ANSWER

Narrowing is tied to a specific variable at a specific point in the control flow. It is lost when the compiler cannot prove the value is still the same: in callbacks over a variable that is reassigned later, across function calls for anything it cannot track, and through helpers that return a plain boolean.

function schedule(value: string | number) {
  if (typeof value === "string") {
    setTimeout(() => value.toUpperCase()); // error: value might be reassigned before this runs
  }
  value = 5;
}

function scheduleFixed(value: string | number) {
  if (typeof value === "string") {
    const text = value; // a const cannot change, so the narrowing is safe to capture
    setTimeout(() => text.toUpperCase()); // ok
  }
  value = 5;
}
  • Reassigned captures. A closure may run later, so narrowing only survives into it when the variable is never assigned again afterwards. Copy to a const.
  • Opaque checks. if (isValid(user)) narrows nothing unless isValid is a type guard.
  • filter(Boolean) does not remove null from the element type; a predicate (written or inferred) does.
  • Property narrowing is optimistic. After if (obj.value !== null), a function call that mutates obj does not reset the narrowing. The compiler accepts this unsoundness on purpose, because resetting on every call would make narrowing useless.
  • Generics. Inside a generic function, typeof checks narrow the value but not the type parameter T itself, so the return type often still needs an assertion or overloads.
FOLLOW-UPS
How do you narrow by a computed key, like obj[key]?
Since 5.5 the compiler narrows obj[key] when both obj and key are effectively constant. The portable fix is the same as for closures: read it into a const and narrow that.
typescriptlang.org

Want reps on this? Drill Type Narrowing →

TypeScript

Generics and utility types

Where mid-level interviews spend most of their time. You should be able to write a constrained generic function from memory and explain every built-in utility type.

6
01What are generics, and what problem do they solve?Junior
SHORT ANSWER

A generic is a type parameter: a placeholder filled in by the caller. It lets one function, class or type work over many types while keeping the relationship between its inputs and outputs, which any throws away.

function firstAny(items: any[]): any {
  return items[0];
}

function first<T>(items: T[]): T | undefined {
  return items[0];
}

const lost = firstAny([1, 2, 3]); // any: the link between input and output is gone
const n = first([1, 2, 3]); // number | undefined, inferred from the argument
const s = first(["a", "b"]); // string | undefined
const explicit = first<Date>([]); // T supplied by hand when there is nothing to infer from

The same idea applies to types, interfaces and classes:

interface ApiResponse<T> {
  data: T;
  error: string | null;
}

class Dictionary<V> {
  #entries = new Map<string, V>();

  set(key: string, value: V) {
    this.#entries.set(key, value);
  }

  get(key: string): V | undefined {
    return this.#entries.get(key);
  }
}

const ages = new Dictionary<number>();
ages.set("ada", 36);
ages.set("bob", "forty"); // error: string is not assignable to number

Interview line: A generic links types together. If a function takes a T and returns a T, the caller gets back exactly what they put in, without me knowing what it is.

FOLLOW-UPS
How is T decided?
By inference from the arguments wherever possible. You only pass it explicitly when there is nothing to infer from (useState<User | null>(null), new Map<string, number>()), or to override a too-narrow inference.
Do generics exist at runtime?
No. They are erased with every other type, so you cannot write new T() or value instanceof T. If you need the type at runtime, pass a value that carries it: a constructor, a schema, a type guard.
typescriptlang.org
02What is a generic constraint? Write a type-safe getProperty(obj, key).Mid
SHORT ANSWER

T extends Something restricts what a type parameter may be, which is what lets the function body use that shape. For getProperty, constrain the key to keyof T and return the indexed access type T[K].

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest([1, 2], [3]); // number[]
longest("alice", "bob"); // "alice" | "bob"
longest(10, 20); // error: number has no 'length' property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Ada", tags: ["admin"] };

const name = getProperty(user, "name"); // string
const tags = getProperty(user, "tags"); // string[]
getProperty(user, "email"); // error: "email" is not a key of user
  • keyof T is the union of T's keys: here "id" | "name" | "tags".
  • K extends keyof T lets K be inferred as the one literal the caller passed.
  • T[K] looks up the type of that property, so each call returns a different, exact type.

The trap: Writing key: keyof T without the second type parameter compiles, but the return type becomes the union of every property type (string | number | string[]), because the compiler no longer knows which key was passed.

FOLLOW-UPS
Does extends here mean inheritance?
No. In a constraint it means 'is assignable to'. T extends string | number accepts string, number, and any literal or union of them.
How would you type a pluck(items, key) that returns an array of that property?
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][], returning items.map((item) => item[key]).
typescriptlang.org
03When is a generic the wrong tool?Senior
SHORT ANSWER

When the type parameter appears only once. A generic exists to relate two or more positions; if it relates nothing, it is unknown with extra syntax. And a type parameter that appears only in the return type is a type assertion in disguise.

// Pointless: T is used once, so it constrains nothing.
function logValue<T>(value: T): void {
  console.log(value);
}
function logSimpler(value: unknown): void {
  console.log(value);
}

// Useful: T links the argument to the result.
function wrap<T>(value: T): { value: T } {
  return { value };
}

// Dangerous: the caller picks T and nothing checks it.
async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  return res.json();
}

const user = await getJson<{ id: string }>("/api/user"); // reads like a check; it is a cast
  • Rule of thumb: every type parameter should appear at least twice in the signature.
  • Push constraints down. Prefer <T>(items: T[]) over <A extends unknown[]>(items: A): the simpler the parameter, the better the inference.
  • Defaults (<T = unknown>) keep a generic type convenient when most callers do not care.
FOLLOW-UPS
How do you stop one argument from widening the inferred type?
Wrap the other positions in NoInfer<T> (5.4). In createMachine<S extends string>(states: S[], initial: NoInfer<S>), S is inferred only from states, so a misspelt initial is an error instead of a new member of the union.
typescriptlang.org
04What do keyof, typeof and indexed access types do? How do you derive a type from a value?Mid
SHORT ANSWER

typeof value gives the static type of a value. keyof T gives the union of T's keys. T[K] looks up the type of a property. Together they let you write the data once and compute the types from it, so the two cannot drift apart.

const routes = {
  home: "/",
  settings: "/settings",
  profile: "/u/:id",
} as const;

type Routes = typeof routes; // the type of the value
type RouteName = keyof Routes; // "home" | "settings" | "profile"
type RoutePath = Routes[RouteName]; // "/" | "/settings" | "/u/:id"

function href(to: RouteName): RoutePath {
  return routes[to];
}

interface Order {
  id: string;
  customer: { email: string; addresses: { city: string }[] };
}

type Email = Order["customer"]["email"]; // string
type Address = Order["customer"]["addresses"][number]; // { city: string }

[number] indexes an array or tuple type by 'any numeric index', which yields its element type. It is the standard way to turn a const array into a union.

const SIZES = ["sm", "md", "lg"] as const;
type Size = (typeof SIZES)[number]; // "sm" | "md" | "lg"

const isSize = (value: string): value is Size =>
  (SIZES as readonly string[]).includes(value); // one list drives the type and the runtime check
FOLLOW-UPS
How is this typeof different from JavaScript's?
In an expression, typeof x is the runtime operator and returns a string. In a type position it is a type query and returns the full static type. Same keyword, two languages.
What is keyof of a union?
Only the keys common to every member, because those are the only ones that are safe to access. keyof (A | B) is keyof A & keyof B.
typescriptlang.org
05Which built-in utility types do you use, and what does each one do?Junior
SHORT ANSWER

The everyday ones reshape object types (Partial, Required, Readonly, Pick, Omit, Record), filter unions (Exclude, Extract, NonNullable) and read function types (Parameters, ReturnType, Awaited). They are all ordinary mapped and conditional types that ship with the compiler.

UtilityResult
Partial<T>Every property optional
Required<T>Every property required
Readonly<T>Every property readonly (shallow)
Pick<T, K>Only the listed keys
Omit<T, K>Everything except the listed keys
Record<K, V>An object with keys K and values V
Exclude<U, M>Union members of U not assignable to M
Extract<U, M>Union members of U assignable to M
NonNullable<T>T without null and undefined
Parameters<F>A function's parameters as a tuple
ReturnType<F>A function's return type
Awaited<P>What a promise resolves to, recursively
interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

type PublicUser = Omit<User, "password">;
type Credentials = Pick<User, "email" | "password">;
type UserPatch = Partial<Omit<User, "id">>; // any subset of fields, never the id
type UsersById = Record<User["id"], PublicUser>;

type Event = "click" | "focus" | "submit" | null;
type PointerOrFocus = Extract<Event, "click" | "focus">; // "click" | "focus"
type RealEvent = NonNullable<Event>; // "click" | "focus" | "submit"

declare function createUser(input: Omit<User, "id">): Promise<User>;
type CreateInput = Parameters<typeof createUser>[0]; // Omit<User, "id">
type Created = Awaited<ReturnType<typeof createUser>>; // User

The trap: Omit does not check its keys, so Omit<User, "pasword"> compiles and removes nothing. It also does not distribute over unions: applied to a discriminated union it flattens the members into one object type and the discrimination is lost.

FOLLOW-UPS
How do you fix Omit for a union?
Make it distribute: type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never. The conditional type runs once per member and re-unions the results.
How would you make just one property optional?
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>. Composing the built-ins usually beats writing a new mapped type.
typescriptlang.org
06Implement Partial, Pick, Omit, Exclude and ReturnType yourself.Senior
SHORT ANSWER

The object utilities are mapped types ([K in keyof T]) with modifiers; the union utilities are distributive conditional types; the function utilities use infer to pull a type out of a pattern.

type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] }; // -? removes optionality
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyRecord<K extends PropertyKey, V> = { [P in K]: V };

type MyExclude<U, M> = U extends M ? never : U; // distributes over each member of U
type MyExtract<U, M> = U extends M ? U : never;
type MyNonNullable<T> = T & {}; // {} is "anything but null and undefined"

type MyOmit<T, K extends PropertyKey> = MyPick<T, MyExclude<keyof T, K>>;

type MyParameters<F> = F extends (...args: infer P) => unknown ? P : never;
type MyReturnType<F> = F extends (...args: never[]) => infer R ? R : never;
type MyAwaited<T> = T extends PromiseLike<infer U> ? MyAwaited<U> : T;

If you are asked how you would know these are right, the answer is a type-level test. Equal compares two types exactly, and Expect fails to compile when it is given false:

Proving it
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyExclude<U, M> = U extends M ? never : U;
type MyOmit<T, K extends PropertyKey> = MyPick<T, MyExclude<keyof T, K>>;
type MyReturnType<F> = F extends (...args: never[]) => infer R ? R : never;
type MyAwaited<T> = T extends PromiseLike<infer U> ? MyAwaited<U> : T;

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Todo {
  id: number;
  title: string;
  done: boolean;
}

type Tests = [
  Expect<Equal<MyPick<Todo, "id" | "done">, { id: number; done: boolean }>>,
  Expect<Equal<MyOmit<Todo, "done">, { id: number; title: string }>>,
  Expect<Equal<MyExclude<"a" | "b" | "c", "a">, "b" | "c">>,
  Expect<Equal<MyReturnType<(x: string) => Promise<number>>, Promise<number>>>,
  Expect<Equal<MyAwaited<Promise<Promise<string>>>, string>>,
];
FOLLOW-UPS
Why does the mapped-type version keep readonly and ? from the original?
Mapping over keyof T is a homomorphic mapped type: the compiler knows the keys come from T and copies each property's modifiers across. Mapping over an unrelated union of strings does not.
Why is MyOmit constrained to PropertyKey rather than keyof T?
That matches the built-in. It lets you omit keys that might not exist, which is convenient for generic code and is also exactly why typos are not caught.
typescriptlang.org

Want reps on this? Drill TypeScript Generics →

TypeScript

Advanced types

Mapped, conditional and template literal types, plus variance. Senior interviews use these to find out whether you understand the type system or only use it.

6
01What is a mapped type? What does the as clause do?Mid
SHORT ANSWER

A mapped type builds a new object type by iterating over a union of keys: { [K in Keys]: ... }. Modifiers (readonly, ?, and their removals -readonly, -?) can be added per key, and an as clause renames or filters the keys as it goes.

type Flags<T> = { [K in keyof T]: boolean };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };

interface Person {
  name: string;
  age: number;
}

type PersonFlags = Flags<Person>; // { name: boolean; age: boolean }

Key remapping (4.1) puts an as clause after the key. Mapping a key to never drops it, which turns a mapped type into a filter:

interface Person {
  name: string;
  age: number;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};
type StringFields = PickByValue<Person, string>; // { name: string }
  • string & K is there because keyof T may include number and symbol keys, and Capitalize only accepts strings.
  • Capitalize, Uncapitalize, Uppercase and Lowercase are intrinsic string-manipulation types.
FOLLOW-UPS
What happens when you map over an array or tuple type?
A homomorphic mapped type applied to an array or tuple maps the element types and returns an array or tuple, not an object with numeric keys. That is why Readonly<string[]> is readonly string[].
Where do you actually use this outside puzzles?
Deriving form state from a model ({ [K in keyof T]: FieldState<T[K]> }), typing event maps, generating handler names for a component API, and stripping or adding readonly at a module boundary.
typescriptlang.org
02What are conditional types, and how does infer work?Senior
SHORT ANSWER

A conditional type is a type-level ternary: T extends U ? X : Y. Inside the extends clause, infer R declares a type variable that the compiler fills in by pattern-matching, so you can pull a piece out of a larger type.

type IsString<T> = T extends string ? true : false;

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string

type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<number>>; // number
type B = Unwrap<string>; // string: not a promise, returned unchanged

type FirstArg<F> = F extends (first: infer A, ...rest: never[]) => unknown ? A : never;
type C = FirstArg<(id: string, force: boolean) => void>; // string

When the checked type is a bare type parameter and you pass it a union, the conditional type is applied to each member separately and the results are unioned. This is distribution, and it is how Exclude works:

type ToArray<T> = T extends unknown ? T[] : never;
type Distributed = ToArray<string | number>; // string[] | number[]

type ToArrayTogether<T> = [T] extends [unknown] ? T[] : never;
type Together = ToArrayTogether<string | number>; // (string | number)[]

type IsNeverWrong<T> = T extends never ? true : false;
type IsNever<T> = [T] extends [never] ? true : false;

type X = IsNeverWrong<never>; // never: distributing over an empty union produces nothing
type Y = IsNever<never>; // true

The trap: Wrapping both sides in a tuple, [T] extends [U], switches distribution off. Forgetting this is the usual reason a conditional type returns a union you did not expect, or never when you pass it never.

FOLLOW-UPS
Where may infer appear?
Only inside the extends clause of a conditional type, and the variable is only in scope in the true branch. Since 4.7 it can carry a constraint: infer U extends string.
Name two built-ins that use it.
ReturnType<F> is F extends (...args: any) => infer R ? R : any, and Awaited<T> recursively infers the value type of a thenable.
typescriptlang.org
03What are template literal types?Senior
SHORT ANSWER

String literal types built with template syntax. Interpolating a union produces every combination, and combined with infer they can parse a string type, which is how routers and CSS-in-TS libraries type paths and class names.

type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`; // "onClick" | "onFocus" | "onBlur"

type Side = "top" | "right" | "bottom" | "left";
type Spacing = `${"margin" | "padding"}-${Side}`; // all 8 combinations

type Id = `user_${number}`;
const ok: Id = "user_42";
const bad: Id = "user_abc"; // error: not assignable to `user_${number}`

With infer, a template literal type becomes a parser. This one extracts the parameter names from a route:

type PathParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | PathParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = PathParams<"/users/:userId/posts/:postId">; // "userId" | "postId"

declare function navigate<P extends string>(
  path: P,
  params: Record<PathParams<P>, string>,
): void;

navigate("/users/:userId/posts/:postId", { userId: "1", postId: "9" }); // ok
navigate("/users/:userId", { id: "1" }); // error: 'id' is not a parameter of this path

The trap: Unions multiply. Two 50-member unions interpolated together produce 2,500 literal types, and the compiler gives up above 100,000. Keep the inputs small.

typescriptlang.org
04Write a DeepReadonly<T>. Why does it need a conditional?Senior
SHORT ANSWER

A type may refer to itself, which lets it walk a nested structure. DeepReadonly maps every property to readonly and recurses into the ones that are objects. The conditional is the base case: without it the recursion would try to map over primitives and functions.

type DeepReadonly<T> = T extends (...args: never[]) => unknown
  ? T // leave functions alone
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T; // primitives are the base case

interface Config {
  api: { url: string; retries: number };
  features: string[];
}

declare const config: DeepReadonly<Config>;

config.api.url = "https://example.com"; // error: 'url' is a read-only property
config.features.push("beta"); // error: 'push' does not exist on 'readonly string[]'

The same shape gives you DeepPartial, and a recursive alias is the natural way to describe JSON:

type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

const payload: Json = { user: { tags: ["a", "b"], active: true } }; // ok
const invalid: Json = { createdAt: new Date() }; // error: Date is not Json
  • Arrays work because a mapped type over an array type maps its elements: string[] becomes readonly string[].
  • Date, Map and Set are objects too, so this simple version maps over their members. A production version special-cases them (ReadonlyMap, ReadonlySet).
  • Recursion depth is limited; very deep or self-referential data can hit 'Type instantiation is excessively deep'.
typescriptlang.org
05What are covariance and contravariance? What do the in and out annotations do?Senior
SHORT ANSWER

Variance describes how subtyping of a type argument carries over to the generic type. Output positions are covariant (a producer of Dog is a producer of Animal); input positions are contravariant (a consumer of Animal is a consumer of Dog). out T and in T declare that intent explicitly so the compiler checks it and skips the structural comparison.

class Animal {
  name = "animal";
}
class Dog extends Animal {
  bark() {}
}

// Return types are covariant.
const makeAnimal: () => Animal = (): Dog => new Dog(); // ok

// Parameter types are contravariant.
const handleDog: (dog: Dog) => void = (animal: Animal) => {}; // ok: handles any Animal, so it handles Dogs
const handleAnimal: (animal: Animal) => void = (dog: Dog) => dog.bark(); // error: it may be given a non-Dog
Position of TVarianceAnnotationAssignability
Only produced (return type, readonly property)Covariantout TBox<Dog> to Box<Animal>
Only consumed (parameter)Contravariantin TBox<Animal> to Box<Dog>
BothInvariantin out TNeither direction
interface Producer<out T> {
  get(): T;
}
interface Consumer<in T> {
  accept: (value: T) => void;
}
interface Cell<in out T> {
  get(): T;
  set: (value: T) => void;
}

TypeScript is deliberately unsound in one familiar place: mutable arrays are treated as covariant.

class Animal {
  name = "animal";
}
class Dog extends Animal {
  bark() {}
}

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // allowed
animals.push(new Animal()); // also allowed... and now dogs[1].bark() throws at runtime

The trap: Parameter contravariance is only enforced under strictFunctionTypes, and only for function-typed properties. Methods written with method syntax (accept(value: T): void) are still compared bivariantly. Declaring callbacks as properties (onChange: (value: T) => void) gets you the strict check.

FOLLOW-UPS
Why would you add in or out if the compiler can already work variance out?
Documentation of intent, faster type-checking of deeply recursive generics, and an error at the declaration if someone later uses T in the wrong position. They do not change behaviour; a wrong annotation is rejected.
Why can a React onChange that accepts a ChangeEvent<HTMLInputElement> not be passed where ChangeEvent<HTMLElement> handlers are expected?
Contravariance. That handler may read event.target.value, so it cannot safely be called with an event from an arbitrary element. A handler for the wider type can be used for the narrower one, never the reverse.
typescriptlang.org
06Type puzzles: write First, Last, TupleToUnion, Length and If.Senior
SHORT ANSWER

Tuple puzzles come down to three moves: index with [number] or ["length"], pattern-match with a variadic tuple and infer, and branch with a conditional type.

type First<T extends readonly unknown[]> = T extends readonly [infer Head, ...unknown[]] ? Head : never;
type Last<T extends readonly unknown[]> = T extends readonly [...unknown[], infer Tail] ? Tail : never;
type Length<T extends readonly unknown[]> = T["length"];
type TupleToUnion<T extends readonly unknown[]> = T[number];
type Concat<A extends readonly unknown[], B extends readonly unknown[]> = [...A, ...B];
type If<C extends boolean, Then, Else> = C extends true ? Then : Else;

type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Tests = [
  Expect<Equal<First<[1, 2, 3]>, 1>>,
  Expect<Equal<First<[]>, never>>,
  Expect<Equal<Last<["a", "b", "c"]>, "c">>,
  Expect<Equal<Length<readonly ["x", "y"]>, 2>>,
  Expect<Equal<TupleToUnion<["sm", "md", "lg"]>, "sm" | "md" | "lg">>,
  Expect<Equal<Concat<[1], ["a", true]>, [1, "a", true]>>,
  Expect<Equal<If<true, "yes", "no">, "yes">>,
];

Interview line: I talk through the base case first, then the recursive or pattern-matching case, and I write one or two Expect<Equal<...>> lines before the implementation so the interviewer can see me checking the edge cases: the empty tuple, never, a union.

FOLLOW-UPS
Why is First<T> = T[0] not quite right?
For the empty tuple, [][0] is undefined rather than never, which usually is not what the puzzle wants. The infer version handles the empty case explicitly.
Should this kind of type appear in application code?
Rarely. It belongs in libraries and shared utilities, where the complexity is paid once and hidden. In feature code, a type that needs a comment to be understood is usually a sign that the data model could be simpler.
typescriptlang.org

Want reps on this? Drill TypeScript Generics →

TypeScript

Config, modules and declarations

The questions that separate people who have written TypeScript from people who have set it up: compiler flags, declaration files, the build pipeline, and migrating a real codebase.

9
01What does strict turn on, and which other compiler options do you care about?Mid
SHORT ANSWER

strict is a bundle of checks, the most important being strictNullChecks and noImplicitAny. Without it TypeScript is closer to a linter than a type system. Beyond strict, noUncheckedIndexedAccess is the flag most worth adding, and isolatedModules is required by every modern bundler.

Flag (all included in strict)What it catches
noImplicitAnyA parameter or variable that would silently become any
strictNullChecksUsing a value that may be null or undefined
strictFunctionTypesUnsafe callback parameter types (contravariance)
strictBindCallApplyWrong arguments to bind, call and apply
strictPropertyInitializationClass properties never assigned in the constructor
noImplicitThisthis with an implied any type
useUnknownInCatchVariablesTreating a caught error as any instead of unknown
alwaysStrictParses in strict mode and emits "use strict"
tsconfig.json for a bundled React app
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,

    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "skipLibCheck": true
  }
}
  • noUncheckedIndexedAccess: array[i] and record[key] become T | undefined.
  • exactOptionalPropertyTypes: name?: string no longer accepts an explicit undefined.
  • noEmit: the bundler emits JavaScript; tsc only type-checks.
  • skipLibCheck: do not re-check .d.ts files in node_modules. Faster, and avoids errors you cannot fix.
  • target / lib: which syntax to emit, and which built-in APIs the types assume exist.
FOLLOW-UPS
You inherit a project with strict: false. What do you do?
Turn the flags on one at a time, noImplicitAny first and strictNullChecks second, fixing or explicitly suppressing errors per directory, so the change lands as several reviewable pull requests and not as one that touches every file.
What is the difference between target and lib?
target controls the syntax of the emitted JavaScript. lib controls which runtime APIs the type checker believes are available. Neither adds polyfills.
typescriptlang.org
02How does TypeScript get from source to something a browser runs?Mid
SHORT ANSWER

Two separate jobs: type-checking and emitting JavaScript. tsc can do both, but modern toolchains split them. A fast transpiler (esbuild, SWC, Babel) strips the types file by file, with no type information, and tsc --noEmit runs alongside to do the actual checking.

  1. Transpile: the bundler removes type syntax and compiles JSX. Each file is handled in isolation, which is why it is fast and why it cannot catch type errors.
  2. Type-check: tsc --noEmit in the editor, in a pre-commit hook and in CI. This is the step that fails the build on a type error.
  3. Bundle: modules are resolved, tree-shaken, split and minified.

The consequence worth saying out loud: your dev server will happily run code that does not type-check. The types are enforced by the editor and by CI, not by the runtime.

  • isolatedModules makes the compiler reject code a single-file transpiler cannot handle correctly.
  • Libraries additionally emit .d.ts files (declaration: true) so consumers get types without the source.
  • Node can now run .ts files directly by stripping types. That only works for erasable syntax, so enums, namespaces and constructor parameter properties are out; erasableSyntaxOnly enforces the subset.
FOLLOW-UPS
Why does Babel or esbuild not just type-check too?
Type-checking needs the whole program: to check one file you must resolve the types of everything it imports. Stripping syntax needs only the file itself, which is what makes it parallel and fast.
How do you speed up type-checking on a large repo?
Incremental builds (incremental, tsBuildInfoFile), project references so packages are checked independently, skipLibCheck, and avoiding pathological types (huge unions, deep recursion, enormous intersections). The native port of the compiler (TypeScript 7) targets an order-of-magnitude speed-up on top of that.
typescriptlang.org
03What are import type and isolatedModules for?Mid
SHORT ANSWER

import type marks an import as types only, so it is guaranteed to be erased. That matters because a single-file transpiler cannot tell whether an imported name is a type or a value. isolatedModules makes the compiler flag anything that would be ambiguous to such a tool.

import type { User } from "./models"; // erased entirely
import { createUser, type Role } from "./models"; // createUser stays, Role is erased

export type { User }; // a type re-export must say so
export { createUser };
  • Without the type keyword, a transpiler looking at one file cannot know whether User is a type (drop the import) or a value (keep it, and its side effects).
  • verbatimModuleSyntax makes the rule mechanical: anything marked type is removed, everything else is emitted exactly as written.
  • Type-only imports also cannot create circular-dependency problems at runtime, because they do not exist at runtime.

The trap: Under isolatedModules you cannot re-export a type with a plain export { User }, and const enum values from other files cannot be relied on, because both need knowledge of another file to compile correctly.

FOLLOW-UPS
Does importing a type add to the bundle?
No. Types are erased, so a type-only import contributes zero bytes, even if the module it comes from is large.
typescriptlang.org
04What is a .d.ts file, and what does declare do?Mid
SHORT ANSWER

A declaration file contains types with no implementation: it describes the shape of JavaScript that exists somewhere else. declare is the keyword that says 'this exists at runtime, emit nothing for it'. Libraries ship .d.ts files (or you install them from @types/*), and you write your own for globals and non-code imports.

globals.d.ts
// A file with no imports or exports is a global script: these are visible everywhere.
declare const __APP_VERSION__: string; // injected by the bundler at build time

declare function gtag(command: "event", name: string, params?: Record<string, unknown>): void;

// Teach the compiler what importing an asset returns.
declare module "*.svg" {
  const url: string;
  export default url;
}

// Minimal types for a JavaScript package that ships none.
declare module "legacy-charts" {
  export function render(el: HTMLElement, data: number[]): void;
}
  • declare const is an ambient declaration. A plain const in a .d.ts would be an error, because declaration files cannot contain values.
  • Being ambient, it can be included any number of times without a duplicate-definition error.
  • export const would make the file a module and the constant module-scoped, not global.
Where types come fromExample
Bundled with the package"types" or "exports" in its package.json
DefinitelyTypednpm i -D @types/lodash
Generated from your own codetsc --declaration
Hand-writtenglobals.d.ts, env.d.ts
FOLLOW-UPS
A library has no types and no @types package. What are your options?
Write a small declare module "name" with just the functions you call; that is better than the shorthand declare module "name";, which types every import as any.
How do you add a typed global from inside a module file?
Wrap it in declare global { ... }. A file with an import or export is a module, so top-level declarations in it are local.
typescriptlang.org
05What is declaration merging, and what is module augmentation?Senior
SHORT ANSWER

When two declarations share a name, the compiler merges them into one. Interfaces merge member by member, and namespaces merge with classes, functions and enums. Module augmentation uses this to add to types you do not own: a global like Window, or a library's interface.

import type { CSSProperties } from "react";

// Extend a global interface. The wrapper is needed because this file is a module.
declare global {
  interface Window {
    analytics?: { track(event: string): void };
  }
}

window.analytics?.track("page_view"); // typed

// Extend a library's interface: allow CSS custom properties in the style prop.
declare module "react" {
  interface CSSProperties {
    [customProperty: `--${string}`]: string | number;
  }
}

const style: CSSProperties = { "--accent": "#e4572e" }; // ok after the augmentation
  • Interfaces with the same name merge. Non-function members must agree on their type; methods become overloads, later declarations first.
  • Type aliases never merge. A second type X is a duplicate identifier error.
  • Namespaces merge with a class, function or enum of the same name, which is how static members and nested types are added in older declaration files.

Interview line: This is the real reason library authors publish interfaces instead of type aliases for anything users may need to extend: themes, session objects, route maps, environment variables.

FOLLOW-UPS
How do you type process.env or import.meta.env?
Augment the interface the runtime declares: namespace NodeJS { interface ProcessEnv { API_URL: string } } for Node, or interface ImportMetaEnv { readonly VITE_API_URL: string } for Vite.
What goes wrong with augmentation?
It is global and invisible at the use site. Two packages augmenting the same interface with conflicting types produce errors that are hard to trace, and an augmentation in a file that is not part of the program silently does nothing.
typescriptlang.org
06How do modules work in TypeScript? What about namespaces?Junior
SHORT ANSWER

TypeScript uses standard ES modules: a file with a top-level import or export is a module with its own scope, and anything else is a global script. Namespaces are the pre-ES-module way of grouping code and today survive mainly inside declaration files.

user.ts
export interface User {
  id: number;
  name: string;
}

export class UserStore {
  #users = new Map<number, User>();

  add(user: User) {
    this.#users.set(user.id, user);
  }

  byId(id: number): User | undefined {
    return this.#users.get(id);
  }
}

export default UserStore; // a default export: one per module, imported under any name
app.ts
import UserStore, { type User } from "./user";

const store = new UserStore();
const ada: User = { id: 1, name: "Ada" };
store.add(ada);
  • Named exports are generally preferred over default exports: they rename consistently, autocomplete better and survive refactors.
  • moduleResolution decides how an import specifier becomes a file. Use bundler for an app built by Vite, webpack or Next, and nodenext for code Node runs directly.
  • Path aliases (paths in tsconfig) only affect type-checking. The bundler or runtime needs the same mapping configured separately.
  • Barrel files (index.ts that re-exports) tidy up imports but can defeat tree-shaking and slow down tooling when they grow.
FOLLOW-UPS
What does esModuleInterop do?
It lets you write import express from "express" for a CommonJS module that uses module.exports = ..., by emitting a small helper that wraps the module as a default export.
When is a namespace still reasonable?
In .d.ts files describing a global library, and for declaration merging. For application code, an ES module does everything a namespace does and bundlers understand it.
typescriptlang.org
07What are decorators, and how do they work in TypeScript?Mid
SHORT ANSWER

A decorator is a function applied with @name to a class or class member; it receives the thing it decorates and may wrap or replace it. They are used for cross-cutting behaviour such as logging, validation and dependency injection. TypeScript 5 implements the standard decorators; the older experimentalDecorators flavour is what Angular and NestJS were built on.

function logged<This, Args extends unknown[], Return>(
  method: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const name = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log("call " + name + "(" + args.join(", ") + ")");
    const result = method.call(this, ...args);
    console.log("return " + result);
    return result;
  };
}

class Calculator {
  @logged
  add(a: number, b: number) {
    return a + b;
  }
}

new Calculator().add(2, 3);
stdout
call add(2, 3)
return 5
Standard (TS 5.0+)Legacy (experimentalDecorators)
Method decorator receives(value, context)(target, propertyKey, descriptor)
Parameter decoratorsNot supportedSupported
Type metadataNoemitDecoratorMetadata
Used byNew code and librariesAngular, NestJS, TypeORM

The trap: The two are not compatible. A decorator written for one signature fails under the other, so check which mode a codebase uses before answering 'how do you write a decorator'.

FOLLOW-UPS
Where do decorators show up in React?
Mostly historically: MobX's @observable and @observer, and Redux's @connect on class components. Hooks replaced almost all of it, so in a React codebase today they are rare.
What can be decorated?
Classes, methods, accessors, fields and (with the accessor keyword) auto-accessors. Not standalone functions, which is why function composition or higher-order functions do that job in React.
typescriptlang.org
08How do you type asynchronous code? What is the type of a caught error?Junior
SHORT ANSWER

An async function always returns Promise<T>, where T is the type of what you return. await unwraps it. A promise's rejection has no type, so the variable in catch is unknown and must be narrowed before you read from it.

interface User {
  id: string;
  name: string;
}

async function getUser(id: string): Promise<User> {
  const res = await fetch("/api/users/" + id);
  if (!res.ok) throw new Error("Request failed with " + res.status);
  return (await res.json()) as User; // json() is Promise<any>; validate at a real boundary
}

async function load() {
  // Runs both requests in parallel and keeps each position's type.
  const [user, count] = await Promise.all([getUser("1"), Promise.resolve(3)]);

  try {
    await getUser("missing");
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(message);
  }

  return { user, count }; // { user: User; count: number }
}

type Resolved = Awaited<ReturnType<typeof getUser>>; // User

When failure is an expected outcome and not an exception, put it in the return type, where the compiler can make callers handle it:

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

async function attempt<T>(work: () => Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await work() };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
  }
}

async function demo() {
  const result = await attempt(() => fetch("/api/health"));
  if (!result.ok) return result.error.message;
  return result.value.status; // narrowed: value only exists when ok is true
}

The trap: Awaiting inside a loop runs the requests one after another. If they are independent, start them all and await Promise.all(...). And a promise you neither await nor .catch fails silently; the no-floating-promises lint rule exists for that.

FOLLOW-UPS
Promise.all vs allSettled vs race vs any?
all rejects as soon as one input rejects. allSettled waits for everything and reports each outcome. race settles with the first to settle, either way. any resolves with the first to fulfil and rejects only if all reject.
Why can you not write Promise<User, HttpError>?
Anything can be thrown in JavaScript, including from code you call, so a rejection type could never be guaranteed. The language reflects that by typing it unknown.
typescriptlang.org
09How would you migrate a large JavaScript codebase to TypeScript?Senior
SHORT ANSWER

Incrementally, with the app shippable at every step. Let TypeScript and JavaScript coexist (allowJs), convert from the leaves of the dependency graph inward, start with loose settings, and tighten the compiler flags one at a time once most files are converted.

  1. Set up side by side. Add a tsconfig with allowJs: true and strict: false, wire tsc --noEmit into CI, and install the @types/* packages for your dependencies.
  2. Type the foundations first. Shared utilities, API clients and domain models have no internal dependencies and are imported everywhere, so typing them improves inference across the app immediately.
  3. Convert leaf to root. Rename .js to .ts in small pull requests. A file's imports should already be typed when you reach it.
  4. Allow escape hatches, but track them. Use // @ts-expect-error with a reason rather than any, and lint against new explicit any.
  5. Ratchet the strictness. Enable noImplicitAny, then strictNullChecks, then full strict, each as its own piece of work.
  6. Validate the boundaries. Once the inside is typed, add runtime validation where data enters, so the types describe reality.
  • checkJs with JSDoc annotations gives type-checking in files you have not renamed yet.
  • @ts-expect-error is better than @ts-ignore: it fails when the error it suppresses goes away, so suppressions do not outlive their reason.
  • New code is written in TypeScript from day one, so the untyped surface only shrinks.

Interview line: The goal is not zero any on day one. It is that every week there is less untyped code than the week before, and nothing new is added to it.

FOLLOW-UPS
How do you measure progress?
Percentage of files converted, count of any and @ts-expect-error, and which strict flags are on. A type-coverage tool in CI stops the number going backwards.
What is the most common mistake?
Turning on strict everywhere at once and then silencing thousands of errors with any. You end up with TypeScript syntax and none of the guarantees.
typescriptlang.org

Want reps on this? Drill TypeScript Types & Config →

TypeScript

TypeScript with React

Where the two halves of the interview meet. Most React roles assume TypeScript, and these are the typing questions that come up while you are live-coding a component.

11
01How do you type a component's props, including children and default values?Junior
SHORT ANSWER

Declare an interface or type for the props and annotate the destructured parameter. Optional props get a ?, defaults go in the destructuring, and children is typed as ReactNode.

interface ButtonProps {
  variant?: "primary" | "ghost"; // optional; the default is set below
  disabled?: boolean;
  onClick: () => void;
  children: ReactNode; // anything React can render
}

function Button({ variant = "primary", disabled = false, onClick, children }: ButtonProps) {
  return (
    <button className={"btn btn-" + variant} disabled={disabled} onClick={onClick}>
      {children}
    </button>
  );
}

const ok = <Button onClick={() => {}}>Save</Button>;
const bad = <Button variant="danger" onClick={() => {}}>Delete</Button>; // error: "danger" is not a variant
  • Use a union of literals, not string, for anything with a fixed set of values. It is checked and it autocompletes.
  • defaultProps is no longer supported on function components in React 19; default parameter values replace it.
  • PropTypes was the runtime predecessor of all this. It is removed in React 19, and TypeScript catches the same mistakes at compile time.
FOLLOW-UPS
Should you use React.FC?
It works, and since the React 18 types it no longer adds an implicit children prop. But it adds nothing over annotating the props parameter, and it cannot express generic components, so most codebases skip it.
Should you annotate the return type?
Usually not; let it infer. If you do, ReactNode is the right annotation for a component.
react.dev
02What is the difference between ReactNode, ReactElement and JSX.Element?Mid
SHORT ANSWER

ReactNode is anything React can render: elements, strings, numbers, null, undefined, booleans, arrays of those. ReactElement is specifically the object a JSX expression produces. JSX.Element is a ReactElement with the generics set to any: the type of a JSX expression.

TypeAcceptsUse for
ReactNodeElements, strings, numbers, null, undefined, booleans, iterableschildren and any 'slot' prop
ReactElementOnly <Something /> objectsProps you intend to cloneElement or inspect
JSX.ElementSame as ReactElement<any, any>Rarely written by hand
function Layout({ children }: { children: JSX.Element }) {
  return <main>{children}</main>;
}

const a = <Layout><p>hello</p></Layout>; // ok
const b = <Layout>hello</Layout>; // error: a string is not a JSX.Element

function BetterLayout({ children }: { children: ReactNode }) {
  return <main>{children}</main>;
}

const c = <BetterLayout>hello</BetterLayout>; // ok
const d = <BetterLayout>{null}</BetterLayout>; // ok

The trap: Typing children as JSX.Element rejects strings, numbers, null, conditionals like {open && <Menu />}, and multiple children. It is almost always a mistake for ReactNode.

FOLLOW-UPS
How do you type children as a function (a render prop)?
As a function type: children: (state: { open: boolean }) => ReactNode.
What is the type of a component itself?
ComponentType<Props> for 'any component accepting these props', or ElementType for 'anything valid as a JSX tag', which includes strings like "div".
react.dev
03How do you type useState, useRef and useReducer?Junior
SHORT ANSWER

useState infers from its initial value, so you only pass a type argument when that value does not tell the whole story (null, an empty array, a union). useRef takes the element type and starts at null for DOM refs. useReducer is typed by typing the reducer, with the action as a discriminated union.

interface User {
  id: string;
  name: string;
}

function Profile() {
  const [count, setCount] = useState(0); // number, inferred
  const [user, setUser] = useState<User | null>(null); // the union has to be stated
  const [tags, setTags] = useState<string[]>([]); // [] alone would be never[]

  const inputRef = useRef<HTMLInputElement>(null); // attached to a DOM node by React
  const timerRef = useRef<number | undefined>(undefined); // a mutable value that survives renders

  useEffect(() => {
    inputRef.current?.focus(); // current is HTMLInputElement | null
    timerRef.current = window.setTimeout(() => setCount((c) => c + 1), 1000);
    return () => window.clearTimeout(timerRef.current);
  }, []);

  return <input ref={inputRef} placeholder={user?.name ?? "Anonymous"} />;
}
type State = { count: number; step: number };

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "setStep"; step: number }
  | { type: "reset" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + state.step };
    case "decrement":
      return { ...state, count: state.count - state.step };
    case "setStep":
      return { ...state, step: action.step }; // step exists only on this action
    case "reset":
      return { count: 0, step: 1 };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });

  function examples() {
    dispatch({ type: "setStep", step: 5 }); // ok
    dispatch({ type: "setStep" }); // error: 'step' is missing
    dispatch({ type: "multiply" }); // error: not a known action
  }

  return <button onClick={() => dispatch({ type: "increment" })}>{state.count}</button>;
}

Because the reducer's return type is annotated as State and every case returns, a new action type that is not handled makes the function fall off the end, which the compiler reports. That is the exhaustiveness check for free.

FOLLOW-UPS
What type do you give a state setter passed as a prop?
Dispatch<SetStateAction<T>>, although it is usually better to pass a narrower callback such as onChange: (value: T) => void so the child does not depend on the parent using useState.
Why does useRef() with no argument fail in React 19's types?
The initial value is now required. Pass null for a DOM ref or undefined for a mutable value. The separate MutableRefObject type is deprecated; every ref's current is now assignable.
react.dev
04How do you type event handlers?Junior
SHORT ANSWER

An inline handler needs no annotation, since the type flows from the JSX attribute. A handler declared separately takes the matching React event type, parameterised by the element: React.ChangeEvent<HTMLInputElement>, React.MouseEvent<HTMLButtonElement>, React.SubmitEvent<HTMLFormElement>.

function SignupForm({ onSignup }: { onSignup: (email: string) => void }) {
  const [email, setEmail] = useState("");

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setEmail(event.target.value);
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Escape") setEmail("");
  }

  function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget); // currentTarget is the form
    onSignup(String(data.get("email")));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={email} onChange={handleChange} onKeyDown={handleKeyDown} />
      <button type="submit" onClick={(event) => event.currentTarget.blur()}>
        Sign up
      </button>
    </form>
  );
}
  • event.currentTarget is the element the handler is attached to, and is precisely typed. event.target is where the event started, which may be a child, so it is only typed as EventTarget (change events are the exception).
  • For a handler passed as a prop, type the prop with the handler alias: onClick?: React.MouseEventHandler<HTMLButtonElement>.
  • Not sure which type? Write the handler inline, hover over the parameter, and copy what the editor shows.

The trap: Older code and tutorials type submit handlers as React.FormEvent. Current React types deprecate it in favour of SubmitEvent, ChangeEvent and InputEvent, which describe events that actually exist.

react.dev
05How do you build a component that accepts every prop of a native element, plus your own?Mid
SHORT ANSWER

Intersect your props with ComponentProps<"button"> (or whichever element), then spread the rest onto the element. The wrapper gets type, disabled, aria-*, event handlers and, in React 19, ref, all typed, without listing any of them.

type ButtonProps = ComponentProps<"button"> & {
  variant?: "primary" | "ghost";
};

function Button({ variant = "primary", className, ...rest }: ButtonProps) {
  const classes = ["btn", "btn-" + variant, className].filter(Boolean).join(" ");
  return <button className={classes} {...rest} />;
}

function Toolbar() {
  const saveRef = useRef<HTMLButtonElement>(null);

  return (
    <Button ref={saveRef} type="submit" aria-label="Save" onClick={(e) => e.currentTarget.blur()}>
      Save
    </Button>
  );
}

const wrong = <Button href="/docs">Docs</Button>; // error: a button has no href

ref is an ordinary prop on function components since React 19, so ComponentProps<"button"> already includes it. Before 19, the same component needed forwardRef:

React 18 and earlier
const Input = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<"input">>(
  function Input(props, ref) {
    return <input ref={ref} {...props} />;
  },
);
  • ComponentProps<typeof Other> borrows the props of another component, which keeps wrappers in sync with what they wrap.
  • Use Omit<ComponentProps<"input">, "onChange"> when you want to replace a native prop with your own signature.
FOLLOW-UPS
How do you expose an imperative API instead of the DOM node?
Type the ref as Ref<Handle>, where Handle is an interface such as { focus(): void; reset(): void }, and return an object of that shape from useImperativeHandle(ref, () => ({ ... })).
Why separate className from ...rest?
So the wrapper's own classes and the caller's are merged. Spreading rest after a hard-coded className would silently let the caller overwrite yours.
react.dev
06How do you write a generic component?Mid
SHORT ANSWER

Give the component function a type parameter and use it in the props, so the item type flows from one prop to the others. The caller never writes the type; it is inferred from the data they pass.

interface ListProps<T> {
  items: T[];
  getKey: (item: T) => string;
  renderItem: (item: T) => ReactNode;
  empty?: ReactNode;
}

function List<T>({ items, getKey, renderItem, empty = null }: ListProps<T>) {
  if (items.length === 0) return empty;
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

const users = [{ id: "1", name: "Ada" }];

const ok = <List items={users} getKey={(user) => user.id} renderItem={(user) => user.name} />;
const bad = <List items={users} getKey={(user) => user.id} renderItem={(user) => user.email} />; // error: no 'email' on the item

The same pattern types any 'value in, value out' control, such as a select whose options are objects:

interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
}

function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
  return (
    <select
      value={options.indexOf(value)}
      onChange={(event) => {
        const next = options[Number(event.target.value)];
        if (next !== undefined) onChange(next);
      }}
    >
      {options.map((option, index) => (
        <option key={index} value={index}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}
FOLLOW-UPS
How do you write a generic arrow-function component in a .tsx file?
const List = <T,>(props: ListProps<T>) => .... The trailing comma stops the parser reading <T> as a JSX tag. A function declaration avoids the issue.
Can a generic component be wrapped in memo or forwardRef?
Not without losing the type parameter, because those helpers are typed over a fixed props type. The usual fixes are a cast back to the generic signature or, in React 19, dropping forwardRef and taking ref as a prop.
react.dev
07A component has props that only make sense in certain combinations. How do you type that?Senior
SHORT ANSWER

As a discriminated union of prop shapes, not as one interface full of optionals. Each variant lists exactly the props it needs, the compiler rejects invalid combinations at the call site, and inside the component a check on the discriminant narrows the props.

type AvatarProps =
  | { kind: "image"; src: string; alt: string }
  | { kind: "initials"; name: string };

function Avatar(props: AvatarProps) {
  if (props.kind === "image") {
    return <img src={props.src} alt={props.alt} />;
  }
  return <span>{props.name.slice(0, 2).toUpperCase()}</span>;
}

const a = <Avatar kind="image" src="/ada.png" alt="Ada" />; // ok
const b = <Avatar kind="initials" name="Ada Lovelace" />; // ok
const c = <Avatar kind="initials" src="/ada.png" />; // error: wrong props for this variant

When there is no natural discriminant, never expresses 'one or the other, not both':

type ActionProps =
  | { href: string; onClick?: never }
  | { onClick: () => void; href?: never };

function Action(props: ActionProps & { children: ReactNode }) {
  return props.href !== undefined ? (
    <a href={props.href}>{props.children}</a>
  ) : (
    <button onClick={props.onClick}>{props.children}</button>
  );
}

const link = <Action href="/docs">Docs</Action>; // ok
const both = <Action href="/docs" onClick={() => {}}>Docs</Action>; // error: cannot pass both

Interview line: If I find myself writing a comment like 'only pass onRetry when status is error', that is a type I have not written yet.

FOLLOW-UPS
Why keep props whole instead of destructuring in the parameter list?
Narrowing follows the props object. Once the fields are pulled apart, a property that exists on only one variant cannot be destructured at all, and the relationship between the discriminant and the rest is harder for the compiler to follow.
typescriptlang.org
08How would you type a polymorphic component with an as prop?Senior
SHORT ANSWER

Make the component generic over an ElementType, and compute its props from that type with ComponentProps<E>. Then as="a" allows href and as="button" does not.

type BoxProps<E extends ElementType> = {
  as?: E;
  children?: ReactNode;
} & Omit<ComponentProps<E>, "as" | "children">;

function Box<E extends ElementType = "div">({ as, children, ...rest }: BoxProps<E>) {
  const Tag: ElementType = as ?? "div";
  return <Tag {...rest}>{children}</Tag>;
}

const plain = <Box className="card">A div by default</Box>;
const link = <Box as="a" href="/docs">A link</Box>; // href is allowed: E is "a"
const wrong = <Box as="button" href="/docs">Nope</Box>; // error: a button has no href
  • The local Tag is annotated as plain ElementType because the compiler cannot prove that the remaining props match an unresolved generic E. The public signature is what stays precise.
  • Polymorphic types are expensive to check and produce long error messages, so keep them to a few primitives (Box, Text, Button).

The trap: This is a place to show judgement, not just skill. Many design systems now prefer composition (<Button asChild><a href="/docs" /></Button>, or a render prop) because it gives the same flexibility with far simpler types.

react.dev
09How do you type a context so consumers do not have to handle undefined everywhere?Mid
SHORT ANSWER

Create the context with null as the default, and expose it only through a custom hook that throws if the provider is missing. The hook's return type is then the non-null value, and a forgotten provider fails loudly instead of rendering with a fake default.

interface User {
  id: string;
  name: string;
}

interface AuthContextValue {
  user: User | null;
  signIn: (user: User) => void;
  signOut: () => void;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const value = useMemo<AuthContextValue>(
    () => ({ user, signIn: setUser, signOut: () => setUser(null) }),
    [user],
  );

  return <AuthContext value={value}>{children}</AuthContext>;
}

export function useAuth(): AuthContextValue {
  const value = useContext(AuthContext);
  if (value === null) throw new Error("useAuth must be used inside <AuthProvider>");
  return value;
}

function Greeting() {
  const { user } = useAuth(); // no null check on the context itself
  return <p>{user ? "Hello, " + user.name : "Signed out"}</p>;
}
  • <AuthContext value={...}> is the React 19 form. Earlier versions write <AuthContext.Provider value={...}>.
  • The useMemo matters for behaviour, not types: a new object every render would re-render every consumer.
  • Avoid createContext({} as AuthContextValue). It type-checks and then fails at runtime with an unhelpful 'undefined is not a function' when the provider is missing.
react.dev
10How do you type a custom hook's return value?Mid
SHORT ANSWER

Let it infer, with one exception: when returning a pair, add as const so the result is a tuple and not an array of a union. Use a generic when the hook stores or returns the caller's data.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((value) => !value), []);
  return [on, toggle] as const; // readonly [boolean, () => void]
}

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = window.localStorage.getItem(key);
    return stored === null ? initial : (JSON.parse(stored) as T);
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

function Settings() {
  const [open, toggleOpen] = useToggle();
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "dark");

  return (
    <button onClick={toggleOpen} onDoubleClick={() => setTheme("light")}>
      {open ? "Close" : "Open"} ({theme})
    </button>
  );
}
  • Tuple for one or two values the caller will want to rename (const [open, toggleOpen] = ...).
  • Object for three or more, so call sites are self-describing and adding a field is not a breaking change.
  • The JSON.parse(...) as T above is an unchecked boundary. For anything that matters, pass a validator or schema into the hook.
FOLLOW-UPS
What does the return type look like without as const?
(boolean | (() => void))[]. Every element could be either, so calling toggle() would not compile without a check.
react.dev
11How do you type a higher-order component?Senior
SHORT ANSWER

Make it generic over the wrapped component's props, constrained to include the props the HOC injects. The returned component takes the original props minus the injected ones, via Omit.

interface WithUserProps {
  user: { id: string; name: string };
}

declare function useCurrentUser(): WithUserProps["user"];

function withUser<P extends WithUserProps>(Wrapped: React.ComponentType<P>) {
  function WithUser(props: Omit<P, keyof WithUserProps>) {
    const user = useCurrentUser();
    return <Wrapped {...(props as P)} user={user} />;
  }
  WithUser.displayName = "withUser(" + (Wrapped.displayName ?? Wrapped.name) + ")";
  return WithUser;
}

function Dashboard({ user, title }: WithUserProps & { title: string }) {
  return <h1>{title + ": " + user.name}</h1>;
}

const DashboardWithUser = withUser(Dashboard);

const ok = <DashboardWithUser title="Home" />; // user is supplied by the HOC
const bad = <DashboardWithUser />; // error: 'title' is missing
  • The props as P assertion is the known rough edge: the compiler cannot prove that Omit<P, K> plus K rebuilds P for a generic P.
  • Setting displayName keeps the component tree readable in React DevTools.

Interview line: I can write this, and I would then say that a custom hook, const user = useCurrentUser(), does the same job with no wrapper component, no assertion and no prop-name collisions. That is why HOCs are rare in new code.

react.dev

Want reps on this? Drill TypeScript Types & Config →

React

React fundamentals

Asked in nearly every React interview at every level. Juniors are expected to know the definitions; seniors are expected to explain why each one is the way it is.

18
01What is React, and what are its main features?Junior
SHORT ANSWER

React is a JavaScript library for building user interfaces out of components. You describe what the UI should look like for a given state, and React works out which DOM updates are needed when that state changes.

  • Declarative. You write the UI as a function of state. You do not write the steps to get from the old screen to the new one.
  • Component-based. The UI is a tree of small, reusable functions, each owning its markup, logic and state.
  • One-way data flow. Data moves down through props; changes are requested upward through callbacks.
  • A virtual DOM with reconciliation. React diffs the new description against the previous one and touches the real DOM as little as it can.
  • Hooks. State, effects and shared logic in plain functions.
  • Renderer-agnostic. The same model drives the browser (React DOM), native apps (React Native) and server rendering.
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

React is a library, not a framework: it renders. Routing, data fetching, bundling and server rendering come from the ecosystem, which is why new projects usually start on a framework such as Next.js or React Router, or on Vite for a client-only app. Create React App is deprecated.

FOLLOW-UPS
What are React's limitations?
It is only the view layer, so every project assembles (or picks a framework for) the rest. The render model takes time to internalise: stale closures, effect dependencies and unnecessary re-renders are learned the hard way. And a client-only React app ships a lot of JavaScript before anything appears, unless you add server rendering.
How is it different from Angular or Vue?
Angular is a full framework with its own templates, dependency injection, router and forms, and it uses two-way binding in forms. Vue sits in between, with templates and a reactivity system that tracks dependencies automatically. React is the smallest of the three in scope, uses JSX, and re-runs component functions instead of tracking fine-grained dependencies.
react.dev
02What is JSX, and how does a browser run it?Junior
SHORT ANSWER

JSX is a syntax extension that lets you write markup inside JavaScript. Browsers cannot run it. A compiler (Babel, SWC, esbuild, TypeScript) turns each tag into a function call that returns a plain object describing the element.

What you write
function Greeting({ name }: { name: string }) {
  return <h1 className="title">Hello, {name}</h1>;
}
What the compiler emits
import { jsxs } from "react/jsx-runtime";

function Greeting({ name }) {
  return jsxs("h1", { className: "title", children: ["Hello, ", name] });
}

// ...which returns an element, a plain object:
// { type: "h1", props: { className: "title", children: ["Hello, ", "Ada"] }, key: null }
  • A component returns one root. Use a fragment (<>...</>) to return siblings.
  • Curly braces embed any JavaScript expression, not statements: no if or for inside JSX.
  • Attributes are camelCase and follow the DOM property names: className, htmlFor, onClick, tabIndex.
  • style takes an object: style={{ marginTop: 8 }}.
  • Lowercase tags are DOM elements; capitalised tags are components. <card /> would look for an HTML element named card.
FOLLOW-UPS
Why className and not class?
JSX attributes become keys of a JavaScript object and map to DOM properties, and the DOM property is className. class is also a reserved word in JavaScript.
Why did older code need import React from "react" in every file?
Before React 17 the compiler emitted React.createElement(...), so React had to be in scope. The automatic runtime imports jsx from react/jsx-runtime itself.
How does JSX protect against XSS?
Everything embedded with {} is rendered as text: React escapes it before it reaches the DOM, so a string containing <script> is displayed, not executed. The escape hatch is dangerouslySetInnerHTML, which is exactly as safe as the HTML you give it.
Can you use React without JSX?
Yes, by calling createElement(type, props, ...children) directly. JSX is only a more readable way to write those calls.
react.dev
03What is the difference between a React element and a React component?Junior
SHORT ANSWER

A component is a function that accepts props and returns a description of UI. An element is that description: a small immutable object saying 'render this type with these props'. Button is a component; <Button /> is an element.

// A component: a function. Defined once.
function Welcome({ name }: { name: string }) {
  return <h1>Hello, {name}</h1>;
}

// An element: a plain object. Created on every render.
const element = <Welcome name="Ada" />;
// { type: Welcome, props: { name: "Ada" }, key: null }
  • Element: cheap, immutable, recreated every render. It is the output of JSX.
  • Component: the function (or class) React calls to get elements.
  • Instance / fiber: React's internal record for a mounted component. This is where state lives.
  • Node (ReactNode): anything renderable, so elements plus strings, numbers, null, arrays.

The trap: Two mistakes follow from blurring these. Calling a component as a function (Welcome({ name })) makes its hooks belong to the caller. Defining a component inside another component creates a brand-new component type on every render, so React unmounts and remounts it each time and its state is lost.

Do not nest component definitions
function Page() {
  // A new function identity on every render of Page:
  // React sees a different component type and remounts it, wiping its state.
  function SearchBox() {
    const [query, setQuery] = useState("");
    return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
  }

  return <SearchBox />;
}
react.dev
04What is the virtual DOM? How is it different from the real DOM and the shadow DOM?Junior
SHORT ANSWER

The virtual DOM is React's in-memory description of the UI: a tree of plain objects. On each update React builds a new tree, compares it with the previous one, and applies only the differences to the real DOM. The shadow DOM is unrelated: it is a browser feature for encapsulating a component's markup and styles.

  1. Render. State changes, and React calls the affected components to produce a new tree of elements.
  2. Reconcile. It diffs the new tree against the previous one to find what changed.
  3. Commit. It applies the minimal set of DOM operations, then runs effects.
What it isWho provides it
Real DOMThe browser's live tree of nodes; changing it can trigger layout and paintThe browser
Virtual DOMA lightweight JavaScript description of what the DOM should beReact (a library concept)
Shadow DOMAn encapsulated subtree with scoped styles, used by web componentsThe browser (a web standard)
  • What it buys you: you write the UI declaratively, and React batches and minimises DOM writes for you. It also makes other render targets possible, such as native views or server-side HTML.
  • What it costs: building and diffing trees takes CPU and memory. It is not automatically faster than careful hand-written DOM code; it is fast enough while being far easier to reason about.

Interview line: The virtual DOM is not what makes React fast. It is what makes a declarative model affordable. The win is that I never write the code that moves the UI from one state to another.

FOLLOW-UPS
Does a re-render mean the DOM was updated?
No. Rendering means React called your component and compared the result. If the output is identical, the commit phase changes nothing in the DOM.
Do all frameworks use a virtual DOM?
No. Svelte compiles components into direct DOM updates, and Solid uses fine-grained signals. They trade React's 're-run the function' model for dependency tracking.
react.dev
05What is the difference between props and state?Junior
SHORT ANSWER

Props are inputs passed in by the parent, read-only from the component's point of view. State is data the component owns and can change over time. A change to either causes a re-render.

PropsState
Owned byThe parentThe component itself
Can the component change it?No, they are read-onlyYes, through the setter
PurposeConfigure a component from outsideRemember something between renders
Changes come fromThe parent re-rendering with new valuesEvents, effects, responses
function Stepper({ step, label }: { step: number; label: string }) { // props
  const [value, setValue] = useState(0); // state

  return (
    <button onClick={() => setValue(value + step)}>
      {label}: {value}
    </button>
  );
}

A useful test when deciding whether something should be state: does it change over time, is it not passed in from a parent, and can it not be computed from other props or state? Only if all three are true does it belong in useState.

The trap: Copying a prop into state (useState(props.value)) only reads the prop on the first render. When the parent later passes a new value, the state does not follow. Either use the prop directly, name it initialValue to make the one-time read explicit, or reset the component with a key.

FOLLOW-UPS
How does a child change something the parent owns?
It cannot change the prop. The parent passes down a callback (onChange), the child calls it, the parent updates its own state, and new props flow back down.
react.dev
06Why must you not mutate state directly? How do you update objects and arrays?Junior
SHORT ANSWER

React decides whether anything changed by comparing references. If you mutate an object or array in place, the reference is the same, so React can skip the re-render, and memoised children and effect dependencies see no change. Always produce a new value.

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);

  function addWrong(todo: Todo) {
    todos.push(todo); // mutates the array React is holding
    setTodos(todos); // same reference: React bails out, nothing re-renders
  }

  const add = (todo: Todo) => setTodos((current) => [...current, todo]);

  const remove = (id: string) =>
    setTodos((current) => current.filter((todo) => todo.id !== id));

  const toggle = (id: string) =>
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)),
    );

  return <p>{todos.length} todos</p>;
}
Instead of (mutates)Use (returns a new value)
push, unshift[...items, item], [item, ...items]
pop, shift, splicefilter, slice, toSpliced
sort, reversetoSorted, toReversed, or copy first
items[i] = xmap, or items.with(i, x)
obj.key = x{ ...obj, key: x }

Nested data has to be copied at every level along the path you are changing:

interface Profile {
  name: string;
  address: { city: string; country: string };
}

function useProfile() {
  const [profile, setProfile] = useState<Profile>({
    name: "Ada",
    address: { city: "London", country: "UK" },
  });

  const moveTo = (city: string) =>
    setProfile((current) => ({
      ...current,
      address: { ...current.address, city },
    }));

  return [profile, moveTo] as const;
}
FOLLOW-UPS
What else relies on immutability besides re-rendering?
React.memo, useMemo and useEffect dependency checks all compare by reference. Each render is also meant to be a snapshot: mutating state makes past renders, closures and concurrent rendering see data that changed underneath them.
Deep updates get ugly. What then?
Flatten the state shape so updates are shallow, or use Immer (useImmer, and built into Redux Toolkit), which lets you write mutating code against a draft and produces the immutable copy for you.
react.dev
07Explain one-way data flow and 'lifting state up'.Junior
SHORT ANSWER

Data moves in one direction: down the tree through props. A child that needs to change something calls a function its parent gave it. When two components need the same data, you move that state up to their closest common ancestor and pass it down to both.

function SearchPage({ products }: { products: { id: string; name: string }[] }) {
  const [query, setQuery] = useState(""); // lifted: both children need it

  return (
    <>
      <SearchBox value={query} onChange={setQuery} />
      <Results items={products.filter((product) => product.name.includes(query))} />
    </>
  );
}

function SearchBox({ value, onChange }: { value: string; onChange: (value: string) => void }) {
  return <input value={value} onChange={(event) => onChange(event.target.value)} />;
}

function Results({ items }: { items: { id: string; name: string }[] }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}
  • There is a single source of truth for each piece of state, so two views of it cannot disagree.
  • To find out why something changed, you follow the props up to the component that owns the state. There is nowhere else to look.
  • The cost is plumbing: lifting state far up leads to prop drilling, which is the point at which composition, context or a store earn their place.
FOLLOW-UPS
Does React have two-way binding?
Not built in. A controlled input is the same effect built from two one-way pieces: value flows down and onChange flows up.
How high should state be lifted?
As high as it needs to be and no higher. State kept close to where it is used re-renders less of the tree and is easier to delete.
react.dev
08What are keys for? What goes wrong when you use the array index as a key?Junior
SHORT ANSWER

A key gives each item in a list a stable identity between renders, so React can tell which item moved, which was added and which was removed. With index keys the identity is the position, not the item: reorder or delete, and React keeps the DOM nodes and component state where they were and just swaps the contents, so state ends up attached to the wrong row.

interface Task {
  id: string;
  label: string;
}

function Checklist({ tasks }: { tasks: Task[] }) {
  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <input type="checkbox" /> {task.label}
        </li>
      ))}
    </ul>
  );
}

Now imagine key={index}, the user ticks the first task, and then that task is deleted. React sees keys 0..n-2 still present and key n-1 gone. It keeps the first <li> (with its ticked checkbox), updates its label to the second task's text, and removes the last <li>. The tick has moved to a task the user never touched.

KeyVerdict
A stable ID from the dataCorrect
Array indexOnly safe for a static list with no reordering, filtering or per-item state
Math.random() or a new UUID in renderWrong: a new key every render remounts every item
No keyReact warns and falls back to the index
  • Keys only need to be unique among siblings, not globally.
  • key is not a prop: the child cannot read it. Pass the ID separately if it needs it.
  • For items created on the client, generate the ID once when the item is created and store it in the data.
FOLLOW-UPS
Can you use key outside a list?
Yes, and it is the idiomatic way to reset a component. <ProfileForm key={userId} /> gives React a different identity when the user changes, so it unmounts the old form and mounts a fresh one with fresh state, with no effect needed to clear the fields.
Is an index key a performance problem or a correctness problem?
Both, but correctness is the one that matters. Performance: inserting at the top re-renders every row. Correctness: uncontrolled inputs, focus, animations and component state stay with the position instead of the item.
react.dev
09What is the difference between controlled and uncontrolled components?Junior
SHORT ANSWER

A controlled input gets its value from React state and reports changes through onChange, so state is the single source of truth. An uncontrolled input keeps its own value in the DOM, and you read it when you need it, through a ref or from the form on submit.

function Controlled() {
  const [email, setEmail] = useState("");
  const isValid = email.includes("@");

  return (
    <>
      <input value={email} onChange={(event) => setEmail(event.target.value)} />
      <button disabled={!isValid}>Subscribe</button>
    </>
  );
}

function Uncontrolled() {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log(inputRef.current?.value); // read from the DOM on demand
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} name="email" defaultValue="" />
      <button>Subscribe</button>
    </form>
  );
}
ControlledUncontrolled
Source of truthReact stateThe DOM
Propsvalue + onChangedefaultValue (+ ref)
Re-renders on each keystrokeYesNo
Good forLive validation, formatting, dependent fields, disabling submitSimple forms, file inputs, wrapping non-React widgets

The trap: value={user?.name} starts as undefined, which React treats as uncontrolled, and then becomes a string: 'A component is changing an uncontrolled input to be controlled'. Give it a defined value from the start: value={user?.name ?? ""}. And a value with no onChange renders a read-only field.

FOLLOW-UPS
Which should you use for a large form?
Controlled inputs re-render on every keystroke, which gets noticeable with many fields. Libraries like React Hook Form keep inputs uncontrolled and subscribe only where needed. React 19's form actions also lean uncontrolled: the action receives a FormData.
Why is <input type="file"> always uncontrolled?
Its value is a file chosen by the user, which script cannot set for security reasons. You read files from the element.
react.dev
10What are the ways to render conditionally, and what is the && pitfall?Junior
SHORT ANSWER

An early return, a ternary, or &&. They are plain JavaScript, since JSX is just expressions. The pitfall: && returns its left operand when that is falsy, and React renders the number 0, so {items.length && <List />} prints a 0 on screen when the list is empty.

interface InboxProps {
  user: { name: string; isAdmin: boolean } | null;
  messages: string[];
}

function Inbox({ user, messages }: InboxProps) {
  if (!user) return <p>Please sign in.</p>; // early return for a whole-component guard

  return (
    <section>
      <h1>Hello, {user.name}</h1>
      {messages.length > 0 ? <p>{messages.length} new messages</p> : <p>All caught up.</p>}
      {user.isAdmin && <a href="/admin">Admin</a>}
    </section>
  );
}
The pitfall and the fix
function Count({ items }: { items: string[] }) {
  return (
    <div>
      {items.length && <p>{items.length} items</p>}
      {items.length > 0 && <p>{items.length} items</p>}
    </div>
  );
}
// With an empty array the first line renders "0". The second renders nothing.
  • React renders strings and numbers (including 0 and NaN). It renders nothing for false, true, null and undefined.
  • For more than two or three cases, map the state to a component with an object lookup or a switch, and avoid nested ternaries.
FOLLOW-UPS
How do you stop a component from rendering anything?
Return null. The component stays mounted, with its state and effects intact; it just outputs nothing.
What is the difference between returning null and not rendering the component at all?
If the parent stops rendering it, the component is unmounted and its state is destroyed. If you want to hide something and keep its state, keep it mounted and hide it with CSS, or use <Activity mode="hidden"> in React 19.2.
react.dev
11What are fragments, and why use one instead of a div?Junior
SHORT ANSWER

A fragment groups several elements without adding a node to the DOM. A component must return a single root, and a wrapper div is not always harmless: it can break flex and grid layouts, produce invalid HTML inside tables and lists, and deepen the DOM for no reason.

function Glossary({ terms }: { terms: { id: string; term: string; meaning: string }[] }) {
  return (
    <dl>
      {terms.map((entry) => (
        // The long form is required here: the shorthand <>...</> cannot take a key.
        <Fragment key={entry.id}>
          <dt>{entry.term}</dt>
          <dd>{entry.meaning}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

function Columns() {
  return (
    <>
      <td>Name</td>
      <td>Email</td>
    </>
  );
}

A <div> between <tr> and <td>, or between <dl> and <dt>, is invalid HTML, and browsers will rearrange it. The fragment disappears at render time and leaves the correct structure.

react.dev
12How does event handling work in React? What is a synthetic event?Mid
SHORT ANSWER

React wraps the browser's native event in a SyntheticEvent, an object with the same interface in every browser. It also uses event delegation: listeners are attached once at the root of the app, not on each element, and React dispatches to your handlers as the event bubbles.

function Row({ id, onRemove }: { id: string; onRemove: (id: string) => void }) {
  function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
    event.preventDefault(); // returning false does nothing in React
  }

  return (
    <form onSubmit={handleSubmit}>
      <button type="button" onClick={() => onRemove(id)}>Remove</button>
    </form>
  );
}
HTMLReact
NamingonclickonClick (camelCase)
ValueA string of codeA function reference
Preventing the defaultreturn false worksMust call event.preventDefault()
The classic mistake: calling instead of passing
function Bad({ save }: { save: () => void }) {
  // save() is called during render, and its return value becomes the handler.
  return <button onClick={save()}>Save</button>; // error: void is not a click handler
}

function Good({ save }: { save: () => void }) {
  return <button onClick={save}>Save</button>;
}
  • Since React 17, events are delegated to the root container, not document, so several React roots (or React inside another app) do not interfere.
  • Event pooling was removed in React 17. You can read the event after an await without event.persist().
  • event.nativeEvent gives you the underlying browser event.
  • Events bubble through the React tree, which for portals is not the same as the DOM tree.
FOLLOW-UPS
How do you pass an argument to a handler?
Close over it with an arrow function: onClick={() => remove(item.id)}. The arrow is created each render, which is fine unless the child is memoised and that prop is what breaks the memoisation.
When do you need a native listener instead?
For events on window or document, and when you need { passive: false } to call preventDefault on wheel or touch events. Add it in an effect with a cleanup.
react.dev
13What are refs, and when should you use them?Mid
SHORT ANSWER

A ref is a mutable box, { current }, that persists for the lifetime of a component and does not trigger a re-render when it changes. It has two jobs: holding a DOM node so you can call imperative methods on it, and holding values the render output does not depend on, such as a timer ID.

function SearchField() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={inputRef} type="search" />
      <button onClick={() => inputRef.current?.focus()}>Focus the field</button>
    </>
  );
}
  • Use a ref for: focus, text selection, scrolling, measuring, media playback, and handing a DOM node to a non-React library.
  • Do not use a ref for: anything you can express declaratively. If the screen should change, it is state.
  • Do not read or write ref.current during render. Do it in event handlers and effects. Render must stay pure.

To let a parent hold a ref to something inside your component, accept ref as a prop. In React 19 that is all it takes; earlier versions required wrapping the component in forwardRef. useImperativeHandle narrows what the parent gets from the raw DOM node to a small API of your choosing.

interface PlayerHandle {
  play(): void;
  pause(): void;
}

function Player({ src, ref }: { src: string; ref?: Ref<PlayerHandle> }) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useImperativeHandle(ref, () => ({
    play: () => void videoRef.current?.play(),
    pause: () => videoRef.current?.pause(),
  }));

  return <video ref={videoRef} src={src} />;
}

function Screen() {
  const playerRef = useRef<PlayerHandle>(null);
  return (
    <>
      <Player ref={playerRef} src="/intro.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
    </>
  );
}
FOLLOW-UPS
useRef vs createRef?
createRef returns a new object every time it is called, so it suits class components, where it is called once in the constructor. In a function component it would reset on every render; useRef returns the same object each time.
What is a callback ref?
A function passed to ref. React calls it with the node when it attaches and with null when it detaches (or, in React 19, calls the cleanup function you returned). It is the tool for measuring a node as soon as it exists, or for collecting refs to a dynamic list.
Ref or state?
If the value affects what is rendered, state. If it is bookkeeping that should survive renders without causing one, a ref.
react.dev
14What does Strict Mode do? Why does my effect run twice?Mid
SHORT ANSWER

<StrictMode> is a development-only tool that deliberately stresses your components to expose bugs. It renders each component twice to catch impure renders, and on mount it runs every effect's setup, cleanup, then setup again to catch missing cleanups. Nothing is doubled in production.

  • Double render: component bodies, state initialisers, updater functions and useMemo callbacks run twice. If the result differs, your render has a side effect.
  • Double effect: setup, cleanup, setup. If the app misbehaves, an effect is not undoing what it did.
  • Double ref callbacks, for the same reason.
  • Deprecation warnings for legacy APIs.
declare function createConnection(roomId: string): { connect(): void; disconnect(): void };

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    // Without this cleanup, Strict Mode leaves two open connections in development,
    // and production leaks one every time roomId changes.
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Room {roomId}</h1>;
}

Interview line: If Strict Mode breaks something, the bug was already there. The fix is to make the effect symmetrical, so setup followed by cleanup leaves no trace, not to remove Strict Mode or guard the effect with a ref.

FOLLOW-UPS
My API request fires twice in development. How do I fix it?
Make the duplicate harmless: abort or ignore the first response in the cleanup. Better still, fetch with a library or framework that de-duplicates and caches requests. In production it fires once.
Why would React ever mount, unmount and remount a component for real?
Fast Refresh already does it in development, and features that hide and restore UI while keeping its state (such as <Activity>) depend on effects being safe to tear down and re-run.
react.dev
15What is a portal, and when do you need one?Mid
SHORT ANSWER

createPortal(children, domNode) renders children into a different part of the DOM while keeping them in the same place in the React tree. You need it when an element must visually escape its parent: modals, tooltips, dropdowns and toasts that would otherwise be clipped by overflow: hidden or trapped in a stacking context.

import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";

interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: ReactNode;
}

export function Modal({ open, onClose, children }: ModalProps) {
  useEffect(() => {
    if (!open) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [open, onClose]);

  if (!open) return null;

  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <div role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body,
  );
}
  • Context still works: the portal's children read providers from where the portal is rendered in React, not where it lands in the DOM.
  • Events bubble through the React tree. A click inside the modal reaches an onClick on the component that rendered it, even though the DOM nodes are not nested.
  • On the server there is no document, so a portal has to be rendered on the client only.

The trap: A portal solves positioning, not accessibility. A real modal also needs focus moved into it, focus trapped while it is open, focus restored on close, Escape to dismiss and the background made inert. The native <dialog> element and the Popover API now give you much of that for free.

react.dev
16What are error boundaries? What do they not catch?Mid
SHORT ANSWER

An error boundary is a component that catches errors thrown while rendering anything below it, and shows a fallback instead of letting the whole app unmount. They catch errors in rendering, in lifecycle methods and in constructors. They do not catch errors in event handlers, asynchronous code, server rendering, or in the boundary itself.

import { Component, type ErrorInfo, type ReactNode } from "react";

interface Props {
  fallback: ReactNode;
  children: ReactNode;
}

class ErrorBoundary extends Component<Props, { hasError: boolean }> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true }; // render phase: switch to the fallback
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error(error, info.componentStack); // commit phase: report it
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

export function App({ children }: { children: ReactNode }) {
  return <ErrorBoundary fallback={<p>Something went wrong.</p>}>{children}</ErrorBoundary>;
}
  • This is the one thing that still requires a class component. In practice most teams use the react-error-boundary package, which wraps it with a reset API and hooks.
  • An error that no boundary catches unmounts the entire React tree. A blank page is judged safer than a UI in a corrupted state.
  • Place boundaries at more than one level: one at the root as a last resort, one per route, and one around anything risky (a third-party widget, a chart, user-generated content).
Not caughtHandle it with
Event handlerstry/catch in the handler, then set error state
setTimeout, promises, async codetry/catch or .catch; your data library's error state
Server-side renderingThe framework's error handling
Errors thrown by the boundary itselfA boundary further up
FOLLOW-UPS
How do you let the user retry?
Reset the boundary's state. The simplest way is to change its key (for example a retry counter), which remounts it and its children. react-error-boundary exposes resetErrorBoundary and resetKeys for this.
How do you get an async error into a boundary?
Rethrow it during render: store it in state and throw on the next render, which is what showBoundary from react-error-boundary does. In React 19, errors thrown inside a transition or Action are routed to the nearest boundary automatically.
try/catch vs an error boundary?
try/catch is imperative and covers the synchronous code inside the block. A boundary is declarative and covers rendering of a whole subtree, including components you did not write.
react.dev
17Class components vs function components: what changed, and do you ever still write a class?Junior
SHORT ANSWER

Function components with hooks are the standard. They are plain functions, share logic through custom hooks, and are the only kind that new features (Server Components, the use hook, Actions, the React Compiler) support. The single remaining reason to write a class is an error boundary.

import { Component, useEffect, useState } from "react";

class ClassCounter extends Component<{ label: string }, { count: number }> {
  state = { count: 0 };

  componentDidMount() {
    document.title = this.props.label + ": " + this.state.count;
  }

  componentDidUpdate() {
    document.title = this.props.label + ": " + this.state.count;
  }

  render() {
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        {this.state.count}
      </button>
    );
  }
}

function FunctionCounter({ label }: { label: string }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = label + ": " + count; // one effect replaces both lifecycle methods
  }, [label, count]);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
ClassFunction equivalent
this.state, this.setStateuseState, useReducer
componentDidMountuseEffect(fn, [])
componentDidUpdateuseEffect(fn, [deps])
componentWillUnmountThe cleanup function returned from an effect
shouldComponentUpdate, PureComponentReact.memo
getDerivedStateFromPropsCompute the value during render
componentDidCatch, getDerivedStateFromErrorNo hook equivalent
HOCs and render props for reuseCustom hooks

The trap: The mapping is a translation aid, not a mental model. An effect is not 'the mount lifecycle'; it synchronises something outside React with the current props and state, and re-runs whenever those change. Thinking in lifecycles is where most stale-dependency bugs come from.

FOLLOW-UPS
Are function components faster?
Not meaningfully. The reasons to prefer them are simpler code, no this, logic reuse through hooks, and compatibility with everything React has shipped since 2019.
Do you need to rewrite existing class components?
No. Classes are still supported and can be mixed freely with function components. Convert when you are changing the component anyway.
react.dev
18What are the ways to style a React application?Junior
SHORT ANSWER

Plain CSS files, CSS Modules, utility classes such as Tailwind, CSS-in-JS, and inline styles. They differ in how styles are scoped, whether there is a runtime cost, and how dynamic values are handled.

ApproachScopingTrade-off
Global CSS / SassNone; naming conventions like BEMSimple and cacheable, but class names collide as the app grows
CSS ModulesClass names made unique per file at build timeNo runtime cost; dynamic values need CSS variables
Utility classes (Tailwind)No custom class names to collideFast to write and small CSS output; long className strings
Runtime CSS-in-JS (styled-components, Emotion)Per componentStyles react to props, but there is a runtime cost and it does not work in Server Components
Zero-runtime CSS-in-JS (vanilla-extract, StyleX, Panda)Per componentTyped styles, extracted to CSS at build time
Inline styleThe elementGood for truly dynamic values; no pseudo-classes or media queries
import styles from "./Button.module.css";

function Button({ active, width }: { active: boolean; width: number }) {
  // Conditional class from a CSS Module, plus one dynamic value inline.
  const className = active ? styles.button + " " + styles.active : styles.button;
  return <button className={className} style={{ width }} />;
}

Interview line: My default is whatever the codebase already uses. Starting fresh, I would pick CSS Modules or Tailwind: both are static CSS with no runtime and work with server rendering.

react.dev

Want reps on this? Drill Rendering & Keys →

React

Hooks

The centre of any modern React interview. Expect to explain what each hook is for, predict what a snippet logs, and find the bug in an effect.

18
01What are hooks, and what problems did they solve?Junior
SHORT ANSWER

Hooks are functions that let a function component use React features: state, effects, context, refs. They arrived in React 16.8 to fix three problems with classes: stateful logic was hard to share, related code was scattered across lifecycle methods, and this was a constant source of bugs.

  • Reuse. Sharing stateful logic used to mean higher-order components or render props, both of which wrap your component in more components. A custom hook is just a function call.
  • Cohesion. A subscription was set up in componentDidMount, updated in componentDidUpdate and torn down in componentWillUnmount. With an effect, setup and cleanup sit together.
  • Simplicity. No this, no binding handlers in the constructor, no class boilerplate.
PurposeHooks
StateuseState, useReducer
Side effectsuseEffect, useLayoutEffect, useInsertionEffect, useEffectEvent
ContextuseContext, use
RefsuseRef, useImperativeHandle
PerformanceuseMemo, useCallback, useTransition, useDeferredValue
Forms and actions (React 19)useActionState, useOptimistic, useFormStatus
OtheruseId, useSyncExternalStore, useDebugValue
// Stateful logic, extracted once and reused by any component.
function useWindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update); // setup and cleanup, side by side
  }, []);

  return width;
}

function Sidebar() {
  const width = useWindowWidth();
  return width < 768 ? null : <aside>Navigation</aside>;
}
FOLLOW-UPS
Do hooks replace Redux, HOCs and render props?
They replace most uses of HOCs and render props. useReducer plus context covers small global state, but a store library still earns its place when many components need selective subscriptions, middleware or devtools.
Can you use hooks in a class component?
No. To use hook-based logic from a class, wrap the class in a function component that calls the hook and passes the result as props.
react.dev
02What are the Rules of Hooks, and why do they exist?Junior
SHORT ANSWER

Call hooks only at the top level of a component or custom hook, never inside conditions, loops, nested functions or after an early return; and call them only from React functions. React identifies each hook by the order in which it is called, so that order has to be identical on every render.

React does not know the names of your state variables. For each component it keeps a list of hook slots, and on every render it hands out slot 1 to the first hook call, slot 2 to the second, and so on. Skip one call and every hook after it reads the wrong slot.

Breaks the rule
function Profile({ userId }: { userId: string | null }) {
  if (userId === null) return null; // an early return before the hooks...

  const [name, setName] = useState(""); // ...means these run on some renders and not others
  useEffect(() => {
    document.title = name;
  }, [name]);

  return <input value={name} onChange={(event) => setName(event.target.value)} />;
}
Same behaviour, stable hook order
function Profile({ userId }: { userId: string | null }) {
  const [name, setName] = useState("");

  useEffect(() => {
    if (userId === null) return; // the condition moves inside the hook
    document.title = name;
  }, [userId, name]);

  if (userId === null) return null; // early return after every hook

  return <input value={name} onChange={(event) => setName(event.target.value)} />;
}
  • The use prefix is a naming convention with teeth: it is how the linter knows a function is a hook and must follow the rules.
  • eslint-plugin-react-hooks enforces both rules (rules-of-hooks) and checks dependency arrays (exhaustive-deps).
  • The one exception is use, which may be called inside conditions and loops.
FOLLOW-UPS
What actually happens if you break the rule?
React throws 'Rendered fewer hooks than expected' (or more), or worse, silently pairs state with the wrong hook. The linter catches it before that.
Why can you not call a hook in an event handler?
A handler runs outside of rendering. Hooks only have meaning while React is rendering a component and can tell which component's slots are being filled.
react.dev
03How does useState work? What are its gotchas?Junior
SHORT ANSWER

useState(initial) returns the current value and a setter. Calling the setter schedules a re-render with the new value. The state variable itself is a constant for that render: it never changes mid-function, which is the root of most useState confusion.

declare function buildInitialRows(): string[];

function Example() {
  const [count, setCount] = useState(0);
  const [rows, setRows] = useState(() => buildInitialRows()); // lazy initialiser: called once

  function handleClick() {
    setCount(count + 1); // "set it to this value"
    setCount((current) => current + 1); // "compute it from the latest value"
  }

  return <button onClick={handleClick}>{count} / {rows.length}</button>;
}
  • The initial value is only used on the first render. useState(buildInitialRows()) still calls the function on every render and throws the result away. Pass the function itself to run it once.
  • Setting replaces; it does not merge. Unlike this.setState in classes, an object must be spread: setForm({ ...form, name }).
  • Same value, no render. If the new value is identical by Object.is, React skips the update. That is why mutating an object and setting it again does nothing.
  • The setter is stable. Its identity never changes, so it is safe to omit from dependency arrays and to pass to memoised children.
  • Updates are batched. Several setter calls in one event produce one re-render.

Interview line: State behaves like a snapshot. Every render gets its own copy of every state variable, and the handlers created in that render see that copy forever.

FOLLOW-UPS
One state object or several state variables?
Separate variables for values that change independently; one object (or a reducer) for values that always change together. Avoid redundant or contradictory state: if it can be computed from other state, do not store it.
When do you need the updater function form?
Whenever the next state depends on the previous one and you cannot be sure the value in your closure is current: several updates in one handler, or updates from timers, subscriptions and async callbacks.
react.dev
04What happens when you call a state setter three times in a row? Is setting state asynchronous?Mid
SHORT ANSWER

The setter does not change the variable; it queues an update and asks React to re-render. All updates queued during one event are batched into a single render. So three calls with count + 1 all compute from the same snapshot and the result is one increment, not three.

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

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    console.log(count);
  }

  return <button onClick={handleClick}>{count}</button>;
}

After the first click the console logs 0, the snapshot this handler closed over, and the button shows 1: three requests to set the value to 0 + 1.

With updater functions
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount((current) => current + 1); // 0 -> 1
    setCount((current) => current + 1); // 1 -> 2
    setCount((current) => current + 1); // 2 -> 3
  }

  return <button onClick={handleClick}>{count}</button>; // shows 3 after one click
}
  1. The update is added to a queue on that component.
  2. React schedules a render. Updates from the same event are batched together.
  3. During the render, React runs the queue in order to compute the new state, then calls the component.
  4. It diffs the result against the previous tree and commits the changes to the DOM.
  5. Layout effects run, the browser paints, then effects run.

The trap: 'Asynchronous' is a misleading word here. The setter returns nothing you can await. The value in the current render will never change no matter how long you wait, because it belongs to that render. The new value exists only in the next one.

FOLLOW-UPS
What changed with batching in React 18?
Batching became automatic everywhere: in timeouts, promises and native event handlers, not just React event handlers. Before 18, two setters inside a setTimeout caused two renders.
How do you run code after the state has been applied?
If it needs the new value, compute that value into a local variable first and use it for both the setter and your logic. If it needs the updated DOM, use an effect that depends on the state. For the rare case where you need the DOM updated synchronously inside a handler, flushSync forces it.
react.dev
05What is useEffect for, and how do the dependency array and cleanup work?Junior
SHORT ANSWER

useEffect synchronises a component with something outside React: a subscription, a timer, the document title, a network connection. The function runs after the render is committed to the screen. The function it returns is the cleanup, which runs before the effect runs again and when the component unmounts.

DependenciesWhen the effect runs
OmittedAfter every render
[]Once, after the first render (and cleanup on unmount)
[a, b]After the first render, and after any render where a or b changed (Object.is)
function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000); // setup
    return () => clearInterval(id); // cleanup
  }, []); // nothing from the component is read, so there is nothing to depend on

  return <time>{now.toLocaleTimeString()}</time>;
}

function PageTitle({ title }: { title: string }) {
  useEffect(() => {
    document.title = title;
  }, [title]); // re-synchronise whenever title changes

  return null;
}
  • The order on an update: render, commit to the DOM, browser paints, cleanup of the previous effect (with the previous props and state), then the new effect.
  • Dependencies are not a choice. Every reactive value the effect reads (props, state, anything derived from them) belongs in the array. If you want fewer dependencies, change the code so the effect reads less.
  • The effect function cannot be async. It must return a cleanup function or nothing, not a promise. Declare an async function inside and call it.
  • Effects do not run on the server, and children's effects run before their parent's.
FOLLOW-UPS
How do you run something when a component unmounts?
Return a cleanup function from an effect. With an empty dependency array it runs only on unmount; with dependencies it also runs before each re-run.
How do you react to a change in one field of an object?
Depend on that field, not on the object: [user.id]. The object may be a new reference on every render even when the field has not changed.
Is useEffect(fn, []) the same as componentDidMount?
Close, but it is the wrong way to think about it. The effect says 'keep this in sync with nothing', which happens to mean once. In development under Strict Mode it runs twice, to check that your cleanup works.
react.dev
06Why does my effect run on every render, or loop forever?Mid
SHORT ANSWER

Because one of its dependencies is a new reference every render, or because the effect sets state that it also depends on. Objects, arrays and functions created during render are never equal to their previous versions, so React thinks they changed.

An infinite loop
interface Filters {
  category: string;
}
declare function search(options: Filters & { limit: number }): Promise<string[]>;

function Results({ filters }: { filters: Filters }) {
  const [data, setData] = useState<string[]>([]);

  const options = { ...filters, limit: 20 }; // a brand-new object on every render

  useEffect(() => {
    search(options).then(setData); // setData -> re-render -> new options -> effect runs again
  }, [options]);

  return <p>{data.length} results</p>;
}
Depend on primitives
interface Filters {
  category: string;
}
declare function search(options: Filters & { limit: number }): Promise<string[]>;

function Results({ filters }: { filters: Filters }) {
  const [data, setData] = useState<string[]>([]);
  const { category } = filters;

  useEffect(() => {
    const options = { category, limit: 20 }; // built inside: no longer a dependency
    search(options).then(setData);
  }, [category]); // a primitive compares by value

  return <p>{data.length} results</p>;
}
CauseFix
No dependency array, and the effect sets stateAdd the array
An object or array dependency built during renderDepend on its primitive fields, or build it inside the effect
A function dependency defined in the componentMove it inside the effect, or wrap it in useCallback
The effect updates the state it readsUse an updater function (setCount((c) => c + 1)) so the state is not a dependency
The effect only derives a value from props or stateDelete the effect and compute the value during render
FOLLOW-UPS
Can you just leave the dependency out and silence the linter?
You can, and you will ship a stale-closure bug: the effect keeps using values from the render in which it last ran. A suppressed exhaustive-deps warning is almost always a real defect waiting for the right sequence of events.
react.dev
07What is the difference between useEffect and useLayoutEffect?Mid
SHORT ANSWER

Timing. useEffect runs after the browser has painted, so it does not block the screen from updating. useLayoutEffect runs synchronously after React has updated the DOM but before the browser paints, so anything it changes is included in the first paint. Use it only when you must measure the DOM and adjust before the user sees a flicker.

  1. React renders and commits changes to the DOM.
  2. useLayoutEffect callbacks run. The DOM is updated but nothing is on screen yet.
  3. The browser paints.
  4. useEffect callbacks run.
function Tooltip({ anchorTop, children }: { anchorTop: number; children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    // Measured and corrected before the browser paints: the tooltip never
    // appears in the wrong place. With useEffect it would flash there first.
    if (ref.current) setHeight(ref.current.getBoundingClientRect().height);
  }, [children]);

  return (
    <div ref={ref} style={{ position: "absolute", top: anchorTop - height }}>
      {children}
    </div>
  );
}
useEffectuseLayoutEffect
RunsAfter paintBefore paint, synchronously
Blocks the screenNoYes
Use forFetching, subscriptions, logging, most thingsMeasuring layout, positioning, restoring scroll

The trap: Because it blocks painting, slow code in a layout effect makes the whole UI feel sluggish. Default to useEffect and switch only when you can see the flicker you are fixing.

FOLLOW-UPS
What is useInsertionEffect?
An even earlier hook that fires before layout effects, meant for CSS-in-JS libraries to inject style tags before anything reads layout. Application code should not need it.
What about server rendering?
Neither kind of effect runs on the server. A component that depends on a layout effect to look right will render its unmeasured state in the server HTML, so render that part on the client only or give it a sensible fallback.
react.dev
08What is a stale closure in a hook, and how do you fix it?Mid
SHORT ANSWER

Every render creates new functions that close over that render's props and state. A stale closure is a function from an old render that is still running, in a timer, subscription or memoised callback, and still sees the old values. It happens when a dependency array claims the function needs nothing that it actually reads.

The bug
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // count is 0 here, forever: this closure is from the first render
    }, 1000);
    return () => clearInterval(id);
  }, []); // the lie: the effect reads count but does not list it

  return <p>{count}</p>; // goes to 1 and stays there
}
The fix
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount((current) => current + 1); // asks React for the latest value; reads nothing stale
    }, 1000);
    return () => clearInterval(id);
  }, []); // now true: the effect depends on nothing

  return <p>{count}</p>;
}
FixWhen it fits
Updater functionThe stale value is the state you are setting
Add the dependencyRe-running the effect when it changes is acceptable
A ref holding the latest valueYou need the current value without re-subscribing
useEffectEvent (React 19.2)The effect should react to some values but only read others
Reading a value without reacting to it
declare function createConnection(roomId: string): {
  connect(): void;
  disconnect(): void;
  on(event: "connected", listener: () => void): void;
};
declare function notify(message: string, theme: string): void;

function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
  // Always sees the latest theme, without being a dependency of the effect.
  const onConnected = useEffectEvent(() => notify("Connected", theme));

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // changing the theme does not tear down the connection

  return <h1>Room {roomId}</h1>;
}
FOLLOW-UPS
A click handler starts a 3-second timeout that alerts count. You click it, then increment twice. What does it show?
The value from when you clicked. The handler belongs to the render in which it was created and closed over that render's count. That is the same mechanism, working as intended.
How does useCallback cause this?
useCallback(fn, []) keeps the first render's function forever. If fn reads state, it reads the first render's state. The dependency array must list everything the function uses.
react.dev
09What is wrong with this component? (An effect that computes state from props.)Mid
SHORT ANSWER

The filtered list is derived data, and it is being stored in state and kept in sync with an effect. That costs an extra render with stale results on every change, and gives two sources of truth that can disagree. Compute it during render instead.

As often written
interface Item {
  id: string;
  name: string;
}

function SearchList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(items);

  useEffect(() => {
    setResults(items.filter((item) => item.name.includes(query)));
  }, [items, query]);

  return (
    <>
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </>
  );
}
Derive it
interface Item {
  id: string;
  name: string;
}

function SearchList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const results = items.filter((item) => item.name.includes(query)); // derived, always in sync

  return (
    <>
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </>
  );
}
You want toInstead of an effect
Transform data for displayCompute it during render (useMemo if it is measurably slow)
Respond to a user actionDo it in the event handler that caused it
Reset all state when a prop changesGive the component a key
Tell the parent that state changedCall the parent's callback in the same handler, or lift the state
Run a chain of state updatesCompute the final state in one handler or a reducer
Fetch dataA data library or the framework; if you must use an effect, handle the race

Interview line: Effects are for synchronising with systems outside React. If nothing external is involved, I probably do not need one.

FOLLOW-UPS
When is an effect the right tool?
Subscriptions, timers, manual DOM work, analytics that should fire because the component was shown, and connecting to anything that is not React: a WebSocket, a map widget, the browser's media APIs.
react.dev
10How do you fetch data in an effect correctly? What are the pitfalls?Mid
SHORT ANSWER

Fetch inside the effect, depend on the inputs of the request, and use the cleanup to cancel or ignore a response that is no longer wanted. Without that cleanup you have a race condition: a slow response for a previous input can arrive last and overwrite the current one.

interface User {
  id: string;
  name: string;
}

type State =
  | { status: "loading" }
  | { status: "success"; user: User }
  | { status: "error"; error: Error };

function UserProfile({ userId }: { userId: string }) {
  const [state, setState] = useState<State>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });

    fetch("/api/users/" + userId, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error("Request failed with " + res.status); // fetch resolves on 404 and 500
        return res.json() as Promise<User>;
      })
      .then((user) => setState({ status: "success", user }))
      .catch((error: unknown) => {
        if (controller.signal.aborted) return; // superseded or unmounted: not an error
        setState({ status: "error", error: error instanceof Error ? error : new Error(String(error)) });
      });

    return () => controller.abort(); // runs when userId changes and on unmount
  }, [userId]);

  if (state.status === "loading") return <p>Loading</p>;
  if (state.status === "error") return <p role="alert">{state.error.message}</p>;
  return <h1>{state.user.name}</h1>;
}
  • Race conditions. userId goes 1 then 2; the response for 1 arrives after the response for 2. Without the abort, the screen shows user 1 under a URL for user 2.
  • Unchecked status. fetch only rejects on network failure. A 500 is a successful promise.
  • Missing states. Every request has loading, error, empty and success. Each needs a UI.
  • No caching. Every mount refetches; two components asking for the same data make two requests.
  • Waterfalls. A parent fetches, renders, and only then a child starts its own fetch.
  • No server rendering. Effects do not run on the server, so the HTML arrives empty.

Interview line: I can write this by hand, and in production I would not. TanStack Query, SWR or RTK Query handle cancellation, caching, deduplication, retries and revalidation; a framework can fetch on the server so the data is in the first HTML.

FOLLOW-UPS
What if the API cannot be aborted?
Use an ignore flag: set let ignore = false in the effect, check it before calling the setter, and set it to true in the cleanup. The request still completes; its result is discarded.
Where should a request go in a class component?
componentDidMount, and again in componentDidUpdate when the relevant prop changed, with the same care about stale responses.
react.dev
11useRef or useState? What is a ref good for besides the DOM?Mid
SHORT ANSWER

Both persist a value across renders. Changing state triggers a re-render; changing a ref does not. So state is for anything the screen depends on, and a ref is for bookkeeping that the render output never reads: timer IDs, the previous value, an 'is this the first run' flag, the latest version of a callback.

useStateuseRef
Changing it re-rendersYesNo
Update is visibleIn the next renderImmediately, on ref.current
Read during renderYesAvoid: render should not depend on it
MutableNo, replace itYes
function Stopwatch() {
  const [elapsed, setElapsed] = useState(0); // shown on screen: state
  const intervalRef = useRef<number | undefined>(undefined); // never shown: ref

  function start() {
    if (intervalRef.current !== undefined) return; // already running
    const startedAt = Date.now() - elapsed;
    intervalRef.current = window.setInterval(() => setElapsed(Date.now() - startedAt), 100);
  }

  function stop() {
    window.clearInterval(intervalRef.current);
    intervalRef.current = undefined;
  }

  useEffect(() => () => window.clearInterval(intervalRef.current), []); // clear it on unmount

  return (
    <>
      <output>{(elapsed / 1000).toFixed(1)}s</output>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  );
}

The classic small exercise is usePrevious, a ref updated after each render:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value; // runs after the render, so during render it still holds the old value
  }, [value]);

  return ref.current;
}

The trap: If you find yourself changing a ref and wondering why the screen did not update, it should have been state. And reading ref.current while rendering makes the output depend on something React cannot track, which is why the linter and the React Compiler flag it.

FOLLOW-UPS
How do you skip an effect on the first render?
Keep a ref isFirstRun, initialised to true; in the effect, if it is true, set it to false and return. It is worth asking whether the logic belongs in an event handler instead, since 'on update but not on mount' usually means 'when the user did something'.
Why not a module-level variable?
A module variable is shared by every instance of the component, and between requests on the server. A ref belongs to one mounted instance.
react.dev
12What is the difference between useMemo, useCallback and React.memo?Mid
SHORT ANSWER

useMemo caches the result of a calculation between renders. useCallback caches a function's identity between renders. React.memo wraps a component so it skips re-rendering when its props are shallowly equal. The first two are mostly useful because of the third: they keep props stable so a memoised child can actually skip.

CachesUse when
useMemo(fn, deps)The value returned by fnThe calculation is expensive, or the result is passed to a memoised child or used as a dependency
useCallback(fn, deps)fn itselfThe function is passed to a memoised child or used as a dependency
memo(Component)The component's rendered outputIt re-renders often with the same props and rendering it is slow
interface Product {
  id: string;
  name: string;
  price: number;
}

const ProductTable = memo(function ProductTable({
  rows,
  onSelect,
}: {
  rows: Product[];
  onSelect: (id: string) => void;
}) {
  return (
    <ul>
      {rows.map((row) => (
        <li key={row.id} onClick={() => onSelect(row.id)}>
          {row.name}
        </li>
      ))}
    </ul>
  );
});

function Catalog({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState<string | null>(null);
  const [dark, setDark] = useState(false);

  const rows = useMemo(
    () => products.filter((product) => product.name.includes(query)).sort((a, b) => a.price - b.price),
    [products, query],
  );
  const handleSelect = useCallback((id: string) => setSelected(id), []);

  return (
    <div className={dark ? "dark" : "light"}>
      <button onClick={() => setDark(!dark)}>Toggle theme ({selected ?? "none"})</button>
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      {/* Toggling the theme re-renders Catalog, but rows and handleSelect keep
          their identity, so the memoised table skips. */}
      <ProductTable rows={rows} onSelect={handleSelect} />
    </div>
  );
}
  • useCallback(fn, deps) is exactly useMemo(() => fn, deps).
  • Memoisation is not free: it costs memory, a dependency comparison on every render, and readability. An incorrect dependency array turns it into a stale-value bug.
  • useCallback on a function passed to a non-memoised child does nothing useful. The child re-renders anyway.
  • The React Compiler inserts this memoisation automatically at build time. In a codebase that uses it, manual useMemo and useCallback are mostly unnecessary.

Interview line: I do not memoise by default. I measure with the Profiler, and before reaching for memo I try the structural fixes: move state down, or pass the expensive part as children.

FOLLOW-UPS
A component wrapped in memo still re-renders. Why?
A prop is a new reference each time (an inline object, array, arrow function or JSX children), or the component reads a context that changed, or its own state changed. memo only compares props, and only shallowly.
Is useMemo a guarantee?
No, it is a performance hint. React may discard the cache, so the code must still be correct if the function runs again. Never rely on it for behaviour, such as running a side effect once.
react.dev
13What is useReducer, and when would you use it instead of useState?Mid
SHORT ANSWER

useReducer moves state logic out of event handlers into one pure function, (state, action) => newState. Components dispatch actions describing what happened, and the reducer decides how state changes. Reach for it when several values change together, when the next state depends on the previous in non-trivial ways, or when many handlers update the same state.

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

type CartAction =
  | { type: "added"; item: Omit<CartItem, "quantity"> }
  | { type: "removed"; id: string }
  | { type: "quantityChanged"; id: string; quantity: number }
  | { type: "cleared" };

function cartReducer(items: CartItem[], action: CartAction): CartItem[] {
  switch (action.type) {
    case "added": {
      const existing = items.find((item) => item.id === action.item.id);
      if (!existing) return [...items, { ...action.item, quantity: 1 }];
      return items.map((item) =>
        item.id === action.item.id ? { ...item, quantity: item.quantity + 1 } : item,
      );
    }
    case "removed":
      return items.filter((item) => item.id !== action.id);
    case "quantityChanged":
      return items.map((item) =>
        item.id === action.id ? { ...item, quantity: action.quantity } : item,
      );
    case "cleared":
      return [];
  }
}

function Cart() {
  const [items, dispatch] = useReducer(cartReducer, []);
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return (
    <>
      <button onClick={() => dispatch({ type: "added", item: { id: "p1", name: "Keyboard", price: 80 } })}>
        Add keyboard
      </button>
      <button onClick={() => dispatch({ type: "cleared" })}>Clear</button>
      <p>Total: {total}</p>
    </>
  );
}
useStateuseReducer
Best forIndependent, simple valuesRelated values with rules about how they change
Update logic livesIn each event handlerIn one function
TestingThrough the componentThe reducer is a pure function: call it and assert
Passing updates downOne callback per operationOne stable dispatch
  • A reducer must be pure: no mutation, no requests, no timers. Same inputs, same output.
  • Name actions after what happened ("added", "submitted"), not after the setter you would have called.
FOLLOW-UPS
How is this different from Redux?
Same idea, smaller scope. useReducer state belongs to one component and dies with it; there is no store, middleware, devtools or selector subscription. Combined with context it can serve a subtree, at the cost of re-rendering every consumer on each change.
Is dispatch stable?
Yes, like state setters. That makes it a good thing to pass through context: consumers that only dispatch never need to re-render.
react.dev
14What is the Context API, and what are its pitfalls?Mid
SHORT ANSWER

Context makes a value available to every component below a provider without passing it through props at each level. Any descendant can read it with useContext. The catch: when the value changes, every component that reads that context re-renders, and there is no way to subscribe to part of it.

type Theme = "light" | "dark";

const ThemeContext = createContext<Theme>("light"); // the default is used only with no provider above

function App() {
  const [theme, setTheme] = useState<Theme>("dark");

  return (
    <ThemeContext value={theme}>
      <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>Toggle</button>
      <Toolbar />
    </ThemeContext>
  );
}

function Toolbar() {
  return <ThemedButton />; // knows nothing about the theme
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={"btn-" + theme}>Save</button>;
}
  • Good for values that many components need and that change rarely: theme, locale, the signed-in user, feature flags, a store or client instance.
  • Poor for state that changes often (the text of an input, a mouse position, a large frequently-updated object), because every consumer re-renders on each change.
The most common mistake, and its fix
interface User {
  name: string;
}
const AuthContext = createContext<{ user: User | null; signOut: () => void } | null>(null);

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  // Without useMemo, this object is new on every render of AuthProvider, and every
  // consumer re-renders even though user has not changed.
  const value = useMemo(() => ({ user, signOut: () => setUser(null) }), [user]);

  return <AuthContext value={value}>{children}</AuthContext>;
}
PitfallMitigation
A new object as value every renderMemoise the value
One large context that changes for many reasonsSplit by concern: separate contexts for state and for dispatch, or for user and theme
Consumers re-render for fields they do not useSplit further, or use a store with selectors (Zustand, Redux, Jotai)
memo does not stop context updatesIt only compares props; move the context read into a smaller child
Hidden dependenciesExpose the context through a custom hook that throws without a provider

Interview line: Context is a way to transport a value, not a state manager. The state still lives in a useState or useReducer somewhere; context only saves you the prop drilling.

FOLLOW-UPS
What is the difference between <ThemeContext value> and <ThemeContext.Provider value>?
They do the same thing. React 19 lets you render the context object directly as the provider; .Provider is the form from earlier versions.
Should you reach for context as soon as you see prop drilling?
Try composition first. Often the intermediate components do not need to know about the data at all if the parent passes the finished element down as children or as a prop.
react.dev
15What is a custom hook? Write one.Junior
SHORT ANSWER

A custom hook is a function whose name starts with use and that calls other hooks. It extracts stateful logic so several components can share it. It shares the logic, not the state: each component that calls the hook gets its own independent state.

function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id); // a newer value cancels the pending update
  }, [value, delayMs]);

  return debounced;
}

function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 300);

  useEffect(() => {
    if (debouncedQuery === "") return;
    const controller = new AbortController();
    fetch("/api/search?q=" + encodeURIComponent(debouncedQuery), { signal: controller.signal }).catch(() => {});
    return () => controller.abort();
  }, [debouncedQuery]); // one request per pause in typing, not one per keystroke

  return <input value={query} onChange={(event) => setQuery(event.target.value)} />;
}
  • The name must start with use, so the linter can apply the Rules of Hooks to it and to its callers.
  • It can take any arguments and return anything: a value, a tuple, an object of values and functions.
  • It follows the same rules as components: top-level calls only, same order every render.
  • A function that calls no hooks is a plain utility. Do not give it a use name.

Hooks worth being able to write from memory: useToggle, useDebouncedValue, usePrevious, useLocalStorage, useMediaQuery, useOnClickOutside, useEventListener, useInterval, useFetch.

FOLLOW-UPS
Two components call the same custom hook. Do they share state?
No. Each call creates its own state and effects. To share the actual state, lift it up and pass it down, put it in context, or use an external store.
When do you extract one?
When the same effect-and-state combination appears in a second component, or when a component's intent is buried under plumbing and a name such as useOnlineStatus() would make it readable.
How do you test a custom hook?
With renderHook from React Testing Library, wrapping state changes in act. Often it is better to test a small component that uses it, since that is how it runs for real.
react.dev
16What is useId for?Mid
SHORT ANSWER

It generates a unique ID that is stable across the server render and the client hydration. Its job is wiring up accessibility attributes: htmlFor with id, aria-describedby, aria-labelledby. It is not for list keys.

function PasswordField() {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>Password</label>
      <input id={id} type="password" aria-describedby={id + "-hint"} />
      <p id={id + "-hint"}>At least 12 characters.</p>
    </div>
  );
}
  • A hard-coded id="password" breaks as soon as the component is rendered twice on one page.
  • A counter or Math.random() produces different values on the server and the client, which is a hydration mismatch.
  • One useId call can serve several related elements: add a suffix.

The trap: Do not use it as a key. Keys must come from your data so they identify the item, not the position in the render.

react.dev
17What do useTransition and useDeferredValue do, and how do they differ?Senior
SHORT ANSWER

Both mark an update as non-urgent, so React can keep the interface responsive: it renders the low-priority update in the background and abandons it if something more urgent, like a keystroke, comes in. useTransition wraps the code that sets the state. useDeferredValue wraps a value you receive and gives you a copy that lags behind.

declare const SlowList: (props: { query: string }) => ReactNode; // wrapped in memo, renders thousands of rows

function FilterWithDeferredValue() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      {/* Urgent: the input updates on every keystroke. */}
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      {/* Not urgent: the list re-renders with the lagging value, and can be interrupted. */}
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <SlowList query={deferredQuery} />
      </div>
    </>
  );
}

function TabsWithTransition() {
  const [tab, setTab] = useState("overview");
  const [isPending, startTransition] = useTransition();

  function select(next: string) {
    startTransition(() => setTab(next)); // the current tab stays interactive while the next one renders
  }

  return (
    <>
      <button onClick={() => select("reports")} disabled={isPending}>
        Reports
      </button>
      <SlowList query={tab} />
    </>
  );
}
useTransitionuseDeferredValue
You controlThe state updateOnly the value (a prop, or state set elsewhere)
Gives youisPending and startTransitionA deferred copy of the value
Typical useNavigation, tab switches, form submissionsFiltering or charting as the user types
  • Neither is a debounce. There is no fixed delay: the work starts immediately and yields to urgent updates. On a fast device there is no visible lag at all.
  • They help with slow rendering, not slow networks. To cut the number of requests, you still debounce.
  • With Suspense, a transition keeps the already-visible content on screen instead of replacing it with a fallback while the next screen loads.
  • The value of a controlled input cannot be set in a transition; typing must stay synchronous.
FOLLOW-UPS
Why does the slow child need to be memoised for useDeferredValue to help?
When query changes, the parent re-renders urgently with the old deferred value. If the child is not memoised it re-renders right then, on the urgent path, which is exactly the work you were trying to defer.
What changed for transitions in React 19?
startTransition accepts async functions. Those are Actions: React tracks the pending state across the await, and errors thrown inside go to the nearest error boundary.
react.dev
18Which hooks do you rarely need, and what are they for?Senior
SHORT ANSWER

useSyncExternalStore subscribes to state that lives outside React. useImperativeHandle customises what a ref exposes. useInsertionEffect is for CSS-in-JS libraries. useDebugValue labels custom hooks in DevTools. useEffectEvent reads the latest props and state from inside an effect without making the effect depend on them.

function subscribe(onChange: () => void) {
  window.addEventListener("online", onChange);
  window.addEventListener("offline", onChange);
  return () => {
    window.removeEventListener("online", onChange);
    window.removeEventListener("offline", onChange);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // the value on the client
    () => true, // the value during server rendering and hydration
  );
}

function StatusBadge() {
  const online = useOnlineStatus();
  return <span>{online ? "Online" : "Offline"}</span>;
}
HookUse it when
useSyncExternalStoreReading a browser API or a third-party store; it is what Redux and Zustand use internally
useImperativeHandleA parent needs methods (focus, scrollTo) rather than your raw DOM node
useInsertionEffectYou are writing a styling library that injects <style> tags
useDebugValueYou want a custom hook to show a readable label in React DevTools
useEffectEventAn effect needs a current value but should not re-run when it changes

useSyncExternalStore exists because of concurrent rendering. React can pause a render halfway; if an external store changes during the pause, different components could render with different versions of the data, a glitch called tearing. The hook guarantees a consistent snapshot, and its third argument provides the value for the server so hydration matches.

FOLLOW-UPS
Why not subscribe in a useEffect and copy the value into state?
It works, with rough edges: the first render shows a default and then flips, a change between render and effect can be missed, and it can tear under concurrent rendering. useSyncExternalStore handles all three.
react.dev

Want reps on this? Drill Hooks & Lifecycles →

React

Rendering and reconciliation

How React decides what to do when something changes. This is where intermediate interviews turn into senior ones: the questions stop being 'what is' and become 'why did this re-render'.

8
01What causes a component to re-render?Junior
SHORT ANSWER

Three things: its own state changed, its parent re-rendered, or a context it reads changed. Note what is missing: 'its props changed'. A component re-renders when its parent does, whether or not the props are different, unless it is wrapped in memo.

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

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Clicked {count}</button>
      <Child />
    </>
  );
}

function Child() {
  console.log("Child rendered"); // logs on every click, although Child takes no props
  return <p>I never change.</p>;
}
  • State change. Calling a setter (or dispatch) with a different value queues a render of that component.
  • Parent render. Rendering is recursive by default: when a component renders, React renders everything it returns.
  • Context change. Every component that reads the context re-renders when the provider's value changes, even through a memoised ancestor.
  • External store. A subscription made with useSyncExternalStore (which is what store libraries use) triggers a render when its snapshot changes.

The trap: 'Re-render' does not mean 'DOM update'. Rendering is React calling your function and comparing the result with last time. If nothing differs, the DOM is not touched. Most re-renders are cheap, and chasing every one of them is the usual premature optimisation.

FOLLOW-UPS
Setting state to the value it already has: does that re-render?
No. React compares with Object.is and bails out. (It may call the component once more before bailing out, but it will not render the children or run effects.)
How do you find out why a component rendered?
React DevTools Profiler, with 'Record why each component rendered' enabled. It names the state, prop, hook or parent responsible.
react.dev
02What are the render phase and the commit phase? Why must rendering be pure?Mid
SHORT ANSWER

In the render phase React calls your components and works out what changed; nothing on screen is touched, and the work can be paused, restarted or thrown away. In the commit phase it applies the changes to the DOM in one synchronous step. Because rendering may run more than once or not be used at all, a component must not have side effects while rendering.

  1. Trigger. The initial mount, or a state update.
  2. Render. React calls the affected components, builds the new tree of elements, and diffs it against the previous one. Pure computation.
  3. Commit. React mutates the DOM, attaches refs, and runs layout effects. Synchronous and uninterruptible, so the user never sees a half-updated screen.
  4. Paint. The browser draws the result.
  5. Passive effects. useEffect cleanups and setups run.
Impure and pure
let renderCount = 0;

function Impure({ items }: { items: string[] }) {
  renderCount++; // mutates something outside the component
  items.sort(); // mutates a prop
  document.title = items.length + " items"; // touches the DOM during render
  return <p>{items.join(", ")}</p>;
}

function Pure({ items }: { items: string[] }) {
  const sorted = [...items].sort(); // local mutation of a fresh copy is fine

  useEffect(() => {
    document.title = sorted.length + " items"; // side effects belong in effects or handlers
  }, [sorted.length]);

  return <p>{sorted.join(", ")}</p>;
}
  • Pure means: given the same props, state and context, return the same JSX, and change nothing that existed before the call.
  • Strict Mode calls components twice in development precisely to make impurity visible.
  • Concurrent rendering, Suspense and the React Compiler all assume purity. An impure component may work today and break when one of them is switched on.
FOLLOW-UPS
Where do side effects go, then?
In event handlers when they are caused by a user action, and in effects when they are caused by the component being displayed. Never in the component body.
Can a render be thrown away?
Yes. If a more urgent update arrives while React is rendering a transition, the in-progress work is discarded and restarted. Nothing was committed, so the user sees no inconsistency, provided the render had no side effects.
react.dev
03What is reconciliation? How does React's diffing algorithm work?Mid
SHORT ANSWER

Reconciliation is how React compares the new element tree with the previous one to find the smallest set of DOM changes. A general tree diff is O(n³), so React uses an O(n) heuristic built on two assumptions: elements of different types produce different trees, and keys identify which children are the same across renders.

ComparingWhat React does
Elements of different types (div to section, A to B)Destroys the old subtree, including all its state, and builds the new one
DOM elements of the same typeKeeps the node and updates only the attributes that changed
Components of the same typeKeeps the instance and its state, passes the new props, and re-renders
Lists of childrenMatches by key; without keys, by position
function Toolbar({ compact }: { compact: boolean }) {
  // Different element type at the same position: the input is destroyed and
  // recreated when compact flips, so whatever the user typed is lost.
  return compact ? (
    <div>
      <input placeholder="Search" />
    </div>
  ) : (
    <section>
      <input placeholder="Search" />
    </section>
  );
}

Children are where keys matter. Insert an item at the top of an unkeyed list and React compares position by position: it rewrites every existing item and appends one at the end. With keys it sees that the old items simply moved down, and inserts one node.

Interview line: Reconciliation compares the previous and next element trees and computes the minimal DOM updates. It runs in linear time thanks to two heuristics: a different type means a different subtree, and keys give list items a stable identity.

FOLLOW-UPS
Is reconciliation the same as the virtual DOM?
The virtual DOM is the data (trees of elements); reconciliation is the algorithm that compares them. Fiber is the implementation of that algorithm since React 16.
Does the diff look at the real DOM?
No. It compares the new elements against React's own record of what it rendered last time. That is why changing the DOM behind React's back leads to surprises.
react.dev
04When does React keep a component's state, and when does it reset it?Mid
SHORT ANSWER

State belongs to a position in the tree. If the same component type is rendered at the same position on the next render, its state is preserved. If the type changes, the position changes, or the key changes, React treats it as a different component: the old one is unmounted and the new one starts fresh.

function Counter({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{label}: {count}</button>;
}

function Scoreboard({ player }: { player: "A" | "B" }) {
  return (
    <>
      {/* 1. Same type, same position: ONE counter. Its count survives the switch. */}
      {player === "A" ? <Counter label="Alice" /> : <Counter label="Bob" />}

      {/* 2. Different keys: two separate counters. Switching resets to 0. */}
      {player === "A" ? <Counter key="A" label="Alice" /> : <Counter key="B" label="Bob" />}

      {/* 3. Different positions: two separate counters. Hiding one destroys its state. */}
      {player === "A" && <Counter label="Alice" />}
      {player === "B" && <Counter label="Bob" />}
    </>
  );
}
  • It is the position in the rendered tree that counts, not where the JSX appears in your source. Case 1 has two JSX tags and one position.
  • A key is part of the position. Changing it is the supported way to reset a component on purpose: <Form key={recordId} />.
  • Removing a component from the tree destroys its state. To keep it, lift the state up, keep the component mounted and hide it, or use <Activity mode="hidden">.
FOLLOW-UPS
A chat app keeps the draft message when you switch contacts. Why, and what is the fix?
The <Chat> component is the same type at the same position, so its state is preserved while only the contact prop changes. Give it key={contact.id} and each conversation gets its own state.
react.dev
05In what order do a parent and child render and run their effects?Mid
SHORT ANSWER

Rendering goes top-down: parent, then child. Effects go bottom-up: the child's effects run before the parent's. On an update, every cleanup from the previous render runs before any of the new effects.

function Parent() {
  const [count, setCount] = useState(0);
  console.log("Parent render");

  useEffect(() => {
    console.log("Parent effect");
    return () => console.log("Parent cleanup");
  });

  return (
    <button onClick={() => setCount(count + 1)}>
      <Child />
    </button>
  );
}

function Child() {
  console.log("Child render");

  useLayoutEffect(() => {
    console.log("Child layout effect");
  });

  useEffect(() => {
    console.log("Child effect");
    return () => console.log("Child cleanup");
  });

  return <span>Click</span>;
}

On mount, without Strict Mode:

stdout
Parent render
Child render
Child layout effect
Child effect
Parent effect

After one click:

stdout
Parent render
Child render
Child layout effect
Child cleanup
Parent cleanup
Child effect
Parent effect
  • Layout effects run during the commit, before the browser paints. Passive effects (useEffect) run after.
  • A cleanup always sees the props and state of the render it was created in.
  • Under Strict Mode in development, each render line appears twice and the mount shows an extra setup, cleanup, setup cycle.
FOLLOW-UPS
Why do children's effects run first?
By the time a parent's effect runs, its whole subtree is mounted and its children's effects have completed, so the parent can safely interact with them (measure them, call methods through refs).
react.dev
06What is React Fiber?Senior
SHORT ANSWER

Fiber is the reconciliation engine React has used since version 16, and also the name of its data structure. Each component instance and DOM node is a fiber: a unit of work linked to its parent, first child and next sibling. Because the tree is a linked list rather than a recursive call stack, React can pause work, resume it, prioritise it or throw it away.

  • Before Fiber the reconciler recursed through the whole tree in one synchronous pass. A large update blocked the main thread until it finished, and input and animations stalled.
  • A fiber holds the component type, its props, its state (the list of hooks), pointers to child, sibling and return, and flags describing what must be done at commit.
  • Two trees. current is what is on screen; workInProgress is being built. On commit React swaps them. If a render is abandoned, the work-in-progress tree is simply discarded.
  • Priorities. Each update is assigned a lane. A keystroke outranks a transition, so React can interrupt the transition's render to handle the keystroke.
Fiber makes possibleHow
Concurrent rendering, transitionsRender work is split into units and can yield to the browser
SuspenseA subtree can be paused and retried later
Error boundariesA failed unit of work can be unwound to the nearest boundary
Returning fragments and strings from renderA fiber does not need to map to exactly one DOM node

Interview line: The stack reconciler could not stop once it started. Fiber turned rendering into a loop over units of work that React controls, which is what allows it to schedule, interrupt and resume. The commit phase is still synchronous, so the DOM is never left half-updated.

FOLLOW-UPS
Is Fiber something you use directly?
No, it is internal. You see it indirectly: hooks are stored on the fiber in call order (hence the Rules of Hooks), and DevTools shows the fiber tree.
react.dev
07What is concurrent rendering?Senior
SHORT ANSWER

Since React 18, rendering can be interrupted. React can start rendering an update, pause to handle something more urgent, and then continue or start over. It is not a mode you switch on for the whole app; it is used when you opt in through features such as transitions, deferred values and Suspense.

  • Urgent updates (typing, clicking, pressing) render synchronously, as always.
  • Transitions (startTransition, useDeferredValue) render in the background in small slices, yielding to the browser between them.
  • The commit is still atomic. However the render was scheduled, the DOM is updated in one go.
FeatureWhat concurrency gives it
useTransition, useDeferredValueA slow render does not block input
SuspenseReact can keep showing the old screen while the next one loads
Streaming SSR and selective hydrationThe server sends HTML in chunks; the client hydrates the part the user touches first
Automatic batchingUpdates from any source are grouped into one render

The trap: Concurrency raises the cost of impurity. A render that is started twice or discarded must not have done anything observable. External mutable state read during render can also change between slices, which is why stores need useSyncExternalStore.

FOLLOW-UPS
How do you enable it?
By creating the root with createRoot (or hydrateRoot), which is the only option since React 19. From there it is feature by feature: an update is concurrent only if it is marked as a transition or deferred.
Is it the same as the old 'Concurrent Mode'?
That was the experimental all-or-nothing version. It was replaced by this opt-in model so existing apps could upgrade without every component having to be concurrency-safe at once.
react.dev
08What is the difference between react and react-dom? How does an app start?Junior
SHORT ANSWER

react is the core: components, elements, hooks. It knows nothing about browsers. react-dom is a renderer that turns React's output into DOM operations. Splitting them lets the same core drive other renderers, such as React Native.

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

function App() {
  return <h1>Hello</h1>;
}

const container = document.getElementById("root");
if (!container) throw new Error("Missing #root element");

createRoot(container).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
PackageProvides
reactuseState, useEffect, createContext, memo, lazy, Suspense
react-domcreatePortal, flushSync, useFormStatus, resource preloading
react-dom/clientcreateRoot, hydrateRoot
react-dom/serverrenderToPipeableStream, renderToReadableStream, renderToString
  • createRoot starts a client-rendered app in an empty container.
  • hydrateRoot attaches React to HTML that was already rendered on the server.
  • ReactDOM.render and ReactDOM.hydrate, which you will see in older code, were removed in React 19.
react.dev

Want reps on this? Drill Rendering & Keys →

React

Performance

Every 'how would you optimise this' question is really asking whether you measure before you fix, and whether you know more than one tool.

10
01A React app feels slow. How do you approach it?Mid
SHORT ANSWER

Measure first, in a production build, to find out which kind of slow it is: too much JavaScript to load, too many re-renders, renders that are individually expensive, or waiting on the network. Each has different fixes, and guessing usually optimises the wrong thing.

  1. Reproduce and measure. React DevTools Profiler shows which components rendered, why, and for how long. The browser's Performance panel shows long tasks. Lighthouse and field data show load metrics.
  2. Classify the problem. Slow to load, slow to respond to input, or janky while scrolling or animating.
  3. Fix the biggest thing. One change at a time, re-measuring after each.
  4. Guard it. A bundle-size budget or a performance test in CI so it does not regress.
SymptomLikely causeTools
Slow first loadToo much JavaScript, render-blocking resources, large imagesCode splitting, lazy loading, server rendering, image optimisation
Typing or clicking lagsA state change re-renders a large treeMove state down, pass content as children, memo, transitions
One interaction freezes the pageAn expensive computation in renderuseMemo, move it off the main thread, do it on the server
Long lists scroll badlyThousands of DOM nodesVirtualisation, pagination
Spinners everywhereRequest waterfalls, no cachingParallel fetches, a query cache, prefetching, server data

The trap: Development builds are several times slower than production and render everything twice under Strict Mode. Never draw conclusions from profiling a dev build.

FOLLOW-UPS
How do you reduce bundle size?
Analyse it first with a bundle visualiser. Then split by route, lazy-load heavy and rarely-used components, replace large dependencies (a date library where Intl would do, a utility library imported whole), make sure imports are tree-shakeable, and keep server-only code on the server.
Which optimisation do you reach for last?
Wrapping things in memo, useMemo and useCallback. They add complexity and can hide the structural problem. Restructuring where state lives usually removes the re-render instead of making it cheaper.
react.dev
02How do you stop an expensive subtree from re-rendering, without memo?Mid
SHORT ANSWER

Change the structure so the state that changes and the subtree that is expensive are no longer in the same component. Either move the state down into a smaller component, or lift the expensive content up and pass it in as children.

The problem
declare function ExpensiveTree(): ReactNode;

function Page() {
  const [text, setText] = useState("");

  return (
    <>
      <input value={text} onChange={(event) => setText(event.target.value)} />
      <ExpensiveTree /> {/* re-renders on every keystroke, though it does not use text */}
    </>
  );
}
Fix 1: move the state down
declare function ExpensiveTree(): ReactNode;

function SearchInput() {
  const [text, setText] = useState("");
  return <input value={text} onChange={(event) => setText(event.target.value)} />;
}

function Page() {
  return (
    <>
      <SearchInput /> {/* only this re-renders */}
      <ExpensiveTree />
    </>
  );
}
Fix 2: lift the content up
declare function ExpensiveTree(): ReactNode;

function ThemePanel({ children }: { children: ReactNode }) {
  const [color, setColor] = useState("#e4572e");

  return (
    <div style={{ color }}>
      <input type="color" value={color} onChange={(event) => setColor(event.target.value)} />
      {children}
    </div>
  );
}

function Page() {
  return (
    <ThemePanel>
      <ExpensiveTree /> {/* created by Page, which does not re-render */}
    </ThemePanel>
  );
}

Fix 2 works because children is an element that Page created. When ThemePanel re-renders, it receives the very same element object as before, and React skips a subtree whose element has not changed.

  • Keep state as close as possible to where it is used.
  • Split a component that mixes fast-changing state with slow-rendering content.
  • Keep unrelated state out of context: every consumer re-renders when it changes.
react.dev
03What is React.memo? What are pure components, and when does memoisation fail?Mid
SHORT ANSWER

memo wraps a component so that React skips re-rendering it when the parent re-renders with shallowly equal props. It is the function-component version of the class PureComponent. It fails whenever a prop is a new reference each time, and it never blocks updates from the component's own state or context.

interface RowProps {
  label: string;
  style: { color: string };
  onSelect: () => void;
}

const Row = memo(function Row({ label, style, onSelect }: RowProps) {
  return (
    <li style={style} onClick={onSelect}>
      {label}
    </li>
  );
});

function List({ labels }: { labels: string[] }) {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <ul data-selected={selected}>
      {labels.map((label) => (
        <Row
          key={label}
          label={label}
          style={{ color: "tomato" }} // a new object every render: memo never skips
          onSelect={() => setSelected(label)} // a new function every render: same problem
        />
      ))}
    </ul>
  );
}
Why a memoised component still re-rendersFix
An inline object or array propHoist it to a constant, or useMemo
An inline function propuseCallback, or pass a stable function such as dispatch
JSX passed as children or another propElements are new objects each render; memoise the element or restructure
It reads a context that changedSplit the context, or read it in a smaller child
Its own state changedNothing to fix: that is a real update
  • The comparison is shallow: each prop is compared with Object.is. Mutating an object in place is therefore invisible to it.
  • memo(Component, arePropsEqual) accepts a custom comparison. It is rarely worth it, and an incorrect one causes stale UI.
  • Memoising costs a comparison on every render. For a cheap component that almost always receives new props, it is pure overhead.
FOLLOW-UPS
What was shouldComponentUpdate?
The class method that let a component decide whether to re-render. PureComponent implemented it as a shallow comparison of props and state. memo is the same idea for props of function components.
Should you wrap everything in memo?
No. Use it where the Profiler shows a component that renders often, with the same props, and is slow to render. With the React Compiler enabled, this is done for you.
react.dev
04What are code splitting and lazy loading? How do React.lazy and Suspense work together?Mid
SHORT ANSWER

Code splitting breaks the bundle into chunks that are downloaded on demand, so the first page load only pays for the code it needs. lazy wraps a dynamic import() in a component; the first time it renders, React suspends, shows the nearest <Suspense> fallback while the chunk downloads, then renders the real component.

import { lazy, Suspense, useState } from "react";

const Settings = lazy(() => import("./Settings")); // a default export
const RevenueChart = lazy(() =>
  import("./charts").then((module) => ({ default: module.RevenueChart })), // a named export
);

export function Dashboard() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <>
      <Suspense fallback={<p>Loading chart</p>}>
        <RevenueChart />
      </Suspense>

      <button onClick={() => setShowSettings(true)}>Settings</button>
      {showSettings && (
        <Suspense fallback={<p>Loading settings</p>}>
          <Settings />
        </Suspense>
      )}
    </>
  );
}
Where to splitWhy
By routeThe biggest win for the least effort; most routers and frameworks do it for you
Heavy componentsCharts, rich text editors, maps, syntax highlighters
Below the fold or behind an interactionModals, drawers, tabs the user may never open
Large libraries used in one placeImport them dynamically inside the handler that needs them
  • Declare lazy components at module level. Inside a component, a new lazy component is created on every render and its state resets.
  • Wrap lazy boundaries in an error boundary: a chunk can fail to load after a deploy or on a bad connection.
  • Preload on intent (hover, focus, route prefetch) so the chunk is usually there before the click.
  • Place Suspense boundaries deliberately. One at the root gives a blank page; one per widget gives a pop-in of spinners.
FOLLOW-UPS
Does lazy support named exports?
Only default exports directly. Either re-export the component as a default from its own file, or map it in the promise as above.
How is this different with Server Components?
Server Components never ship to the browser at all, so their code is not in any chunk. Splitting happens automatically at each "use client" boundary.
react.dev
05How do you render a list of 100,000 rows?Mid
SHORT ANSWER

You do not render 100,000 rows. Virtualisation (or windowing) renders only the rows visible in the scroll viewport plus a small buffer, positions them absolutely inside a spacer as tall as the full list, and swaps rows in and out as the user scrolls.

interface VirtualListProps {
  items: string[];
  rowHeight: number;
  height: number;
}

function VirtualList({ items, rowHeight, height }: VirtualListProps) {
  const [scrollTop, setScrollTop] = useState(0);
  const overscan = 3; // extra rows above and below, so fast scrolling does not show gaps

  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const last = Math.min(items.length, Math.ceil((scrollTop + height) / rowHeight) + overscan);

  return (
    <div style={{ height, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
      <div style={{ height: items.length * rowHeight, position: "relative" }}>
        {items.slice(first, last).map((item, offset) => {
          const index = first + offset;
          return (
            <div key={index} style={{ position: "absolute", top: index * rowHeight, height: rowHeight, left: 0, right: 0 }}>
              {item}
            </div>
          );
        })}
      </div>
    </div>
  );
}
  • The DOM holds a few dozen nodes no matter how long the list is, so memory and layout cost stay flat.
  • In production use a library: TanStack Virtual, react-window or react-virtuoso. Variable row heights, sticky headers and scroll restoration are harder than they look.
Cost of virtualisingMitigation
Browser find-in-page cannot see unrendered rowsProvide an in-app search
Screen readers do not know the list's real sizearia-rowcount, aria-setsize, aria-posinset
Variable-height rows must be measuredUse a library that measures and caches
Fast scrolling can show blank spaceIncrease overscan; keep rows cheap to render
FOLLOW-UPS
When is virtualisation the wrong answer?
When the list is a few hundred simple rows, or when pagination or 'load more' matches how people use the data. CSS content-visibility: auto is a cheaper middle ground: the nodes exist, but the browser skips rendering the off-screen ones.
How do you combine it with infinite scroll?
Fetch pages with cursor-based pagination as the user approaches the end (an IntersectionObserver on a sentinel row), append them to the data, and let the virtualiser render only the visible window of the growing list.
tanstack.com
06A context update is re-rendering half the app. How do you fix it?Senior
SHORT ANSWER

Every consumer of a context re-renders when its value changes, so reduce how often the value changes and how many components consume each piece. Memoise the value, split one large context into several by how often each part changes, and for high-frequency or fine-grained state move to a store with selectors.

interface Todo {
  id: string;
  title: string;
}
type Action = { type: "added"; todo: Todo } | { type: "removed"; id: string };

const TodosContext = createContext<Todo[]>([]);
const TodosDispatchContext = createContext<React.Dispatch<Action>>(() => {});

function reducer(todos: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "added":
      return [...todos, action.todo];
    case "removed":
      return todos.filter((todo) => todo.id !== action.id);
  }
}

function TodosProvider({ children }: { children: ReactNode }) {
  const [todos, dispatch] = useReducer(reducer, []);

  return (
    <TodosContext value={todos}>
      <TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
    </TodosContext>
  );
}

function AddButton() {
  // Reads only dispatch, which never changes: this never re-renders when todos change.
  const dispatch = useContext(TodosDispatchContext);
  return (
    <button onClick={() => dispatch({ type: "added", todo: { id: crypto.randomUUID(), title: "New" } })}>
      Add
    </button>
  );
}
  1. Memoise the value if it is an object, so the provider re-rendering does not by itself notify every consumer.
  2. Split by update frequency. State in one context, dispatch (stable) in another; the user in one, the theme in another.
  3. Push the read down. Have a small leaf component read the context, so the re-render is cheap.
  4. Use a store (Zustand, Redux, Jotai) when components need to subscribe to a slice. Context has no selectors.

The trap: memo on a consumer does not help: a context change re-renders the component from the inside, not through its props.

react.dev
07How do you handle an expensive computation without freezing the UI?Senior
SHORT ANSWER

First avoid repeating it (memoise, or compute it on the server). If it still takes long, make it interruptible with a transition so input stays responsive. If it is pure CPU work unrelated to rendering, move it off the main thread into a Web Worker.

TechniqueGood forLimitation
useMemoRecomputing only when inputs changeThe first computation still blocks
useTransition / useDeferredValueSlow rendering caused by a state changeHelps render work, not one long synchronous function
Chunking with yieldsLoops over large dataYou manage the scheduling yourself
Web WorkerParsing, sorting, image or crypto workData is copied across threads; no DOM access
Do lessPagination, virtualisation, server-side aggregationNeeds API support
// Break a long loop into chunks and let the browser handle input in between.
async function processInChunks<T>(items: T[], handle: (item: T) => void, chunkSize = 500) {
  for (let start = 0; start < items.length; start += chunkSize) {
    for (const item of items.slice(start, start + chunkSize)) handle(item);
    await new Promise<void>((resolve) => setTimeout(resolve, 0)); // yield to the event loop
  }
}
function useSortedInWorker(rows: number[]) {
  const [sorted, setSorted] = useState<number[]>([]);

  useEffect(() => {
    const worker = new Worker(new URL("./sort.worker.ts", import.meta.url), { type: "module" });
    worker.onmessage = (event: MessageEvent<number[]>) => setSorted(event.data);
    worker.postMessage(rows);
    return () => worker.terminate(); // cancel if rows change or the component unmounts
  }, [rows]);

  return sorted;
}

Interview line: My order of preference is: do not do the work, do it once, do it somewhere else, and only then do it in slices. A transition keeps the UI responsive while React renders; it does not make a 500 ms function shorter.

react.dev
08What is the difference between debouncing and throttling? Implement a debounce.Mid
SHORT ANSWER

Debouncing waits until the calls stop: it runs once, after a quiet period. Throttling runs at most once per interval while the calls continue. Debounce a search box; throttle a scroll or resize handler.

function debounce<Args extends unknown[]>(fn: (...args: Args) => void, waitMs: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;

  const debounced = (...args: Args) => {
    clearTimeout(timer); // every call cancels the previous pending one
    timer = setTimeout(() => fn(...args), waitMs);
  };
  debounced.cancel = () => clearTimeout(timer);

  return debounced;
}

function throttle<Args extends unknown[]>(fn: (...args: Args) => void, intervalMs: number) {
  let last = 0;

  return (...args: Args) => {
    const now = Date.now();
    if (now - last < intervalMs) return; // too soon: drop this call
    last = now;
    fn(...args);
  };
}
DebounceThrottle
RunsOnce, after the calls stop for wait msAt most once every interval ms
Typical useSearch-as-you-type, autosave, validating a fieldScroll position, resize, mouse move, rate-limited buttons
Ten calls in one burstOne execution, at the endOne at the start, more if the burst outlasts the interval

In a component, the debounced function must be the same one across renders, and it must be cancelled on unmount:

declare function debounce<Args extends unknown[]>(
  fn: (...args: Args) => void,
  waitMs: number,
): ((...args: Args) => void) & { cancel(): void };

function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState("");

  const debouncedSearch = useMemo(() => debounce(onSearch, 300), [onSearch]);
  useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]);

  return (
    <input
      value={query}
      onChange={(event) => {
        setQuery(event.target.value); // the input itself updates immediately
        debouncedSearch(event.target.value);
      }}
    />
  );
}

The trap: Calling debounce(...) directly in the component body creates a new debounced function, with a new timer, on every render. Nothing is ever cancelled, and every keystroke fires.

FOLLOW-UPS
Debounce or useDeferredValue for a search field?
They solve different problems. Debouncing reduces how many requests you send. useDeferredValue keeps typing responsive while React renders a slow result list. A search box that hits an API usually wants the debounce, and may want both.
What else does a good typeahead need?
Cancellation of the previous request, a minimum query length, a cache keyed by query, keyboard navigation, and the ARIA combobox roles so it works with a screen reader.
09What is the React Compiler?Senior
SHORT ANSWER

A build-time tool that analyses your components and inserts memoisation automatically, doing the job of useMemo, useCallback and React.memo at a finer granularity than people write by hand. It works only on code that follows the Rules of React, and skips components it cannot prove are safe.

What you write with the compiler enabled
interface Item {
  id: string;
  name: string;
  price: number;
}
declare function ExpensiveList(props: { items: Item[]; onPick: (id: string) => void }): ReactNode;

function Catalog({ items, onPick }: { items: Item[]; onPick: (id: string) => void }) {
  const [query, setQuery] = useState("");

  // No useMemo, no useCallback. The compiler caches visible and handlePick and
  // reuses them until items, query or onPick change.
  const visible = items.filter((item) => item.name.includes(query));
  const handlePick = (id: string) => onPick(id);

  return (
    <>
      <input value={query} onChange={(event) => setQuery(event.target.value)} />
      <ExpensiveList items={visible} onPick={handlePick} />
    </>
  );
}
  • It runs as a plugin in the build (Babel, with integrations for Vite and Next.js). There is nothing to call at runtime.
  • It assumes components are pure, props and state are not mutated, and hooks are called unconditionally. Code that breaks those rules is left uncompiled, not miscompiled.
  • Its ESLint rules surface the violations, which is useful even before you enable the compiler.
  • A "use no memo" directive opts a single component or hook out while you investigate a problem.

The trap: It makes re-renders cheaper; it does not make structural problems go away. State that lives too high, a context that changes constantly, an unvirtualised list of 10,000 rows and a slow first render are all still yours to fix.

FOLLOW-UPS
Do you delete existing useMemo and useCallback calls?
There is no need to: the compiler works alongside them. New code can simply be written without them, and old ones can be removed over time where the compiler covers the case.
Does that make memoisation knowledge obsolete?
No. You still need to understand referential equality to read a Profiler trace, to work in codebases without the compiler, and to explain why something re-rendered.
react.dev
10What are Core Web Vitals, and what can a React developer do about them?Senior
SHORT ANSWER

Three field metrics Google uses to describe user experience: LCP (how fast the main content appears), INP (how quickly the page responds to interactions) and CLS (how much the layout jumps around). Each maps to specific decisions in a React app.

MetricMeasuresGoodReact-side levers
LCP: Largest Contentful PaintLoading≤ 2.5 sServer-render or statically generate the page; preload the hero image; split and defer non-critical JavaScript
INP: Interaction to Next PaintResponsiveness≤ 200 msKeep handlers short; use transitions for slow renders; avoid re-rendering large trees; hydrate less
CLS: Cumulative Layout ShiftVisual stability≤ 0.1Set image and video dimensions; reserve space with skeletons; do not insert content above what is on screen
  • INP replaced First Input Delay in 2024. It looks at every interaction during the visit, not just the first, so slow re-renders now show up directly in the metric.
  • A client-only React app typically has poor LCP: the browser must download, parse and run the bundle before anything meaningful is drawn.
  • Hydration is main-thread work that competes with the user's first interactions. Server Components and code splitting reduce how much there is to hydrate.
  • Measure in the field (real user monitoring, the web-vitals library), not only in Lighthouse on a fast laptop.
FOLLOW-UPS
A skeleton screen or a spinner?
A skeleton that matches the final layout, because it reserves the space and so prevents layout shift, and it makes the wait feel shorter. Spinners are fine inside a button or a small region.
web.dev

Want reps on this? Drill Vercel React Performance →

React

State management and patterns

Design questions. There is rarely one right answer, so what is being assessed is whether you can name the trade-offs and say when you would choose differently.

11
01Local state, context, or a store: how do you decide where state lives?Mid
SHORT ANSWER

Start by asking what kind of state it is. Most of what people put in a global store is really server data that belongs in a cache, or URL state that belongs in the address bar. For genuine client state, keep it as local as possible and move it outward only when something forces you to.

Kind of stateExamplesWhere it belongs
Local UIOpen or closed, input text, selected tabuseState / useReducer in the component
Shared UITwo siblings need the same valueLifted to the nearest common parent
URLFilters, search, pagination, the current itemSearch params and route params
ServerAnything fetched from an APIA query cache (TanStack Query, SWR, RTK Query) or the framework
App-wide, rarely changingTheme, locale, signed-in userContext
App-wide, frequently changingCart, editor document, complex wizardsA store (Zustand, Redux, Jotai)
FormField values, touched, errorsThe form itself, or a form library
  1. Can it be computed from existing props or state? Then it is not state.
  2. Does only one component need it? Keep it there.
  3. Do a few nearby components need it? Lift it to their common parent.
  4. Should it survive a refresh or be shareable as a link? Put it in the URL.
  5. Does it come from the server? Let a query cache own it.
  6. Is it needed all over the tree? Context if it changes rarely, a store if it changes often.

Interview line: I reach for a store last. Once server data moves into a query cache and filters move into the URL, what is left is usually small enough for local state and one or two contexts.

FOLLOW-UPS
Why not put everything in one global store?
Everything becomes coupled to the store, components stop being reusable, state outlives the screen that needed it, and you rebuild by hand what a query library already does: caching, deduplication, invalidation, loading and error states.
Why is the URL a good place for state?
It survives a refresh, it works with the back button, it can be shared or bookmarked, and the server can read it for the first render. Filters kept in useState have none of that.
react.dev
02What is prop drilling, and how do you avoid it?Junior
SHORT ANSWER

Prop drilling is passing data through components that do not use it, just to get it to a deeply nested one. A little is fine and explicit. When it hurts, the first fix is composition, so the intermediate components never see the data; then context; then a store.

Drilling
interface User {
  name: string;
}

function App({ user }: { user: User }) {
  return <Layout user={user} />;
}
function Layout({ user }: { user: User }) {
  return <Header user={user} />; // Layout does not care about user
}
function Header({ user }: { user: User }) {
  return <Avatar user={user} />; // neither does Header
}
function Avatar({ user }: { user: User }) {
  return <span>{user.name}</span>;
}
Composition: pass the element, not the data
interface User {
  name: string;
}

function App({ user }: { user: User }) {
  // App owns the data, so App builds the element that needs it.
  return <Layout header={<Header avatar={<Avatar user={user} />} />} />;
}
function Layout({ header }: { header: ReactNode }) {
  return <div>{header}</div>;
}
function Header({ avatar }: { avatar: ReactNode }) {
  return <header>{avatar}</header>;
}
function Avatar({ user }: { user: User }) {
  return <span>{user.name}</span>;
}
FixUse whenCost
Composition (children, slot props)The intermediate components are layoutThe top component gets more to assemble
ContextMany components at different depths need the valueImplicit dependency; all consumers re-render on change
A storeThe value changes often or needs selective subscriptionsA dependency and its conventions
FOLLOW-UPS
Is prop drilling always bad?
No. Two or three levels of explicit props are easier to trace than context, because you can follow the data by reading the code. It becomes a problem when adding one prop means editing five files that do not care about it.
react.dev
03What is Redux, and how does it work? When would you not use it?Mid
SHORT ANSWER

Redux keeps application state in a single store outside the component tree. The only way to change it is to dispatch an action, a plain object describing what happened; pure functions called reducers compute the next state from the current state and the action. Components subscribe to the slices they need through selectors.

  • Single source of truth. One store holds the state tree.
  • State is read-only. You never assign to it; you dispatch actions.
  • Changes are made by pure reducers. (state, action) => newState, with no side effects, which is what makes time-travel debugging and replay possible.
store.ts, with Redux Toolkit
import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface CartItem {
  id: string;
  quantity: number;
}

const cartSlice = createSlice({
  name: "cart",
  initialState: [] as CartItem[],
  reducers: {
    // Redux Toolkit runs reducers through Immer, so this "mutation" produces a new state.
    itemAdded(state, action: PayloadAction<string>) {
      const existing = state.find((item) => item.id === action.payload);
      if (existing) existing.quantity += 1;
      else state.push({ id: action.payload, quantity: 1 });
    },
    itemRemoved(state, action: PayloadAction<string>) {
      return state.filter((item) => item.id !== action.payload);
    },
  },
});

export const { itemAdded, itemRemoved } = cartSlice.actions;

export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
A component
import { useDispatch, useSelector } from "react-redux";
import { itemAdded, type AppDispatch, type RootState } from "./store";

export function AddToCart({ productId }: { productId: string }) {
  // Re-renders only when this selected value changes, not on every store update.
  const quantity = useSelector(
    (state: RootState) => state.cart.find((item) => item.id === productId)?.quantity ?? 0,
  );
  const dispatch = useDispatch<AppDispatch>();

  return <button onClick={() => dispatch(itemAdded(productId))}>In cart: {quantity}</button>;
}
  1. The UI dispatches an action.
  2. Middleware sees it first (thunks, logging, async work).
  3. Reducers compute the next state.
  4. The store notifies subscribers.
  5. Selectors run; components whose selected value changed re-render.
ContextReduxZustandTanStack Query
What it isA way to pass a value downA store with strict update rulesA small store with hooksA cache for server data
Selective subscriptionsNoYes, selectorsYes, selectorsYes, per query
Devtools and middlewareNoExtensiveSomeIts own devtools
BoilerplateLowModerate with ToolkitLowLow
Reach for it whenRarely-changing global valuesLarge teams, complex client state, auditabilitySimple shared client stateAnything that comes from an API

The trap: Do not use Redux as a cache for API responses with hand-written loading flags. That is the part of Redux that made people dislike it, and RTK Query or TanStack Query replaces all of it.

FOLLOW-UPS
How does Redux relate to Flux?
Flux is the pattern: actions flow one way through a dispatcher into stores, which update views. Redux is a refinement of it with one store, no dispatcher, and reducers as pure functions.
How do you handle async logic?
With middleware. Thunks (built into Redux Toolkit) are functions that receive dispatch and can await things. Sagas use generators for complex orchestration such as cancellation and races. For plain data fetching, RTK Query generates the whole thing.
Can a reducer dispatch an action or call an API?
No. A reducer must be pure. Side effects belong in middleware or in the event handler that dispatches.
What were connect, mapStateToProps and mapDispatchToProps?
The pre-hooks API: a higher-order component that subscribed a class component to the store and injected state and action creators as props. useSelector and useDispatch replace them.
Redux vs MobX?
Redux is explicit and immutable: plain actions, pure reducers, one store. MobX is implicit and mutable: you change observable objects directly and it tracks which components read what. MobX needs less code; Redux is easier to trace and debug.
redux.js.org
04What is 'server state', and why use a library like TanStack Query for it?Mid
SHORT ANSWER

Server state is data your app does not own: it lives on a server, you hold a copy, and that copy can go stale at any time. Managing it well means caching, deduplicating requests, refetching in the background, retrying, and invalidating after mutations. A query library does all of that behind one hook.

With TanStack Query
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

interface Todo {
  id: string;
  title: string;
}
declare function fetchTodos(): Promise<Todo[]>;
declare function createTodo(title: string): Promise<Todo>;

export function Todos() {
  const queryClient = useQueryClient();

  const { data, isPending, error } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
    staleTime: 60_000, // treat as fresh for a minute: no refetch on remount within that time
  });

  const addTodo = useMutation({
    mutationFn: createTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), // refetch the list
  });

  if (isPending) return <p>Loading</p>;
  if (error) return <p role="alert">{error.message}</p>;

  return (
    <>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={() => addTodo.mutate("New todo")} disabled={addTodo.isPending}>
        Add
      </button>
    </>
  );
}
ProblemWhat the library does
Two components need the same dataOne request, shared by cache key
Data may be out of dateShows the cached copy, refetches in the background (stale-while-revalidate)
The user returns to the tab or reconnectsRefetches automatically
A request failsRetries with backoff, then exposes the error
A mutation changed the dataInvalidate by key and the affected queries refetch
Race conditions and cancellationHandled per query key
Pagination and infinite listsBuilt-in primitives

The important idea is the split. Client state is synchronous and you are its only writer. Server state is asynchronous, shared, and can change without you. Treating them the same way is what led to stores full of isLoading flags.

FOLLOW-UPS
How do optimistic updates work with it?
In onMutate: cancel in-flight queries for that key, save the current cache value, and write the expected result into the cache. In onError, restore the saved value. In onSettled, invalidate so the server's truth replaces your guess.
What is the difference between staleTime and gcTime?
staleTime is how long data counts as fresh, during which no refetch happens. gcTime is how long unused data stays in memory after the last component using it unmounts.
Do you still need this with Server Components?
Less. Data fetched in a Server Component arrives with the HTML and the framework handles caching and revalidation. A client cache is still useful for highly interactive screens: polling, infinite scroll, optimistic mutations.
tanstack.com
05What is a higher-order component? What are its drawbacks?Mid
SHORT ANSWER

A higher-order component is a function that takes a component and returns a new component with extra behaviour or props. It was the main way to share logic across class components. Custom hooks now cover almost every use, without the wrapper.

declare function useAuth(): { user: { name: string } | null };

function withAuth<P extends object>(Wrapped: React.ComponentType<P>) {
  function WithAuth(props: P) {
    const { user } = useAuth();
    if (!user) return <p>Please sign in.</p>;
    return <Wrapped {...props} />;
  }
  WithAuth.displayName = "withAuth(" + (Wrapped.displayName ?? Wrapped.name) + ")";
  return WithAuth;
}

function Dashboard({ title }: { title: string }) {
  return <h1>{title}</h1>;
}

const ProtectedDashboard = withAuth(Dashboard); // applied once, at module level
DrawbackDetail
Wrapper nestingwithRouter(withTheme(connect(...)(Component))) produces a deep tree that is hard to read in DevTools
Invisible propsYou cannot tell from the component which HOC supplies which prop
Name collisionsTwo HOCs injecting a prop of the same name silently overwrite each other
Refs and staticsRefs pointed at the wrapper, and static methods were not copied, unless handled explicitly
TypingRemoving injected props from the public type needs generics and usually an assertion
  • Never apply a HOC inside a render function. It creates a new component type every time, so the subtree unmounts and remounts on each render.
  • A HOC should pass through unrelated props, and must not mutate the component it receives.
  • You still meet them in older code and some library APIs: connect from React Redux, withRouter, React.memo itself.

Interview line: The hook equivalent of withAuth is const { user } = useAuth() inside the component. Same logic, explicit at the call site, no extra component in the tree.

FOLLOW-UPS
Do hooks replace HOCs completely?
Nearly. A HOC can still be the right shape when you need to wrap rendering itself, for example to put a component behind an error boundary, a permission check or a feature flag without touching it.
legacy.reactjs.org
06What is the render props pattern?Mid
SHORT ANSWER

A component that takes a function as a prop and calls it to decide what to render, passing its internal state as arguments. The component owns the behaviour; the caller owns the markup. The function is often passed as children.

interface MousePosition {
  x: number;
  y: number;
}

function MouseTracker({ children }: { children: (position: MousePosition) => ReactNode }) {
  const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });

  return (
    <div onMouseMove={(event) => setPosition({ x: event.clientX, y: event.clientY })}>
      {children(position)}
    </div>
  );
}

function App() {
  return (
    <MouseTracker>
      {({ x, y }) => (
        <p>
          The pointer is at {x}, {y}
        </p>
      )}
    </MouseTracker>
  );
}
  • The prop does not have to be called render. Any prop that is a function returning JSX is a render prop.
  • Like HOCs, it predates hooks. const position = useMousePosition() is simpler when you only need the data.
  • It is still the right tool when the component must control where and how often your markup is rendered: virtualised lists (renderItem), headless UI components, form field wrappers.

The trap: Several render-prop components nested inside each other produce a pyramid of callbacks. That, more than anything, is what hooks were designed to flatten.

react.dev
07What does 'composition over inheritance' mean in React? What is a compound component?Mid
SHORT ANSWER

React components are combined, not extended. You build specific components by rendering generic ones and passing them content through children or other props. A compound component is a set of components designed to be used together that share state implicitly, like <select> and <option> do.

Specialisation by composition
function Dialog({ title, children, footer }: { title: string; children: ReactNode; footer?: ReactNode }) {
  return (
    <section role="dialog" aria-label={title}>
      <h2>{title}</h2>
      <div>{children}</div>
      {footer && <footer>{footer}</footer>}
    </section>
  );
}

// A specific dialog is the generic one, configured. No class hierarchy involved.
function DeleteDialog({ onConfirm }: { onConfirm: () => void }) {
  return (
    <Dialog title="Delete file?" footer={<button onClick={onConfirm}>Delete</button>}>
      <p>This cannot be undone.</p>
    </Dialog>
  );
}
A compound component
const TabsContext = createContext<{ active: string; select: (id: string) => void } | null>(null);

function useTabs() {
  const context = useContext(TabsContext);
  if (!context) throw new Error("Tab components must be rendered inside <Tabs>");
  return context;
}

function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
  const [active, select] = useState(defaultTab);
  return <TabsContext value={{ active, select }}>{children}</TabsContext>;
}

function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { active, select } = useTabs();
  return (
    <button role="tab" aria-selected={active === id} onClick={() => select(id)}>
      {children}
    </button>
  );
}

function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const { active } = useTabs();
  return active === id ? <div role="tabpanel">{children}</div> : null;
}

function Settings() {
  return (
    <Tabs defaultTab="profile">
      <div role="tablist">
        <Tab id="profile">Profile</Tab>
        <Tab id="billing">Billing</Tab>
      </div>
      <TabPanel id="profile">Name, email</TabPanel>
      <TabPanel id="billing">Cards, invoices</TabPanel>
    </Tabs>
  );
}
  • The caller controls structure and order; the parent owns the state. Compare that with one <Tabs items={[...]} /> component that needs a new prop for every variation.
  • This is the cure for boolean-prop explosion (showHeader, withIcon, compact...): expose pieces to compose, not flags to combine.
FOLLOW-UPS
Why does React not use inheritance for components?
Props and composition already cover every case inheritance would: a component can accept arbitrary elements, functions and data. Shared non-UI logic goes in hooks or plain modules. The React team has said they never found a case where a component hierarchy was the better design.
react.dev
08What is the container and presentational component pattern? Is it still relevant?Mid
SHORT ANSWER

It splits components into two roles. Presentational components take props and render markup, with no knowledge of where data comes from. Container components fetch data and hold state, and render presentational components. The idea of separating concerns still holds; the mechanism today is usually a custom hook, not a wrapper component.

interface User {
  id: string;
  name: string;
}
declare function fetchUsers(signal: AbortSignal): Promise<User[]>;

// Presentational: pure function of its props. Trivial to test and to show in Storybook.
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// The "container" logic, as a hook.
function useUsers() {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    const controller = new AbortController();
    fetchUsers(controller.signal).then(setUsers).catch(() => {});
    return () => controller.abort();
  }, []);

  return users;
}

function UsersPage() {
  const users = useUsers();
  return <UserList users={users} />;
}
  • Still valuable: components that only render are reusable, easy to test, and usable in a design system.
  • No longer a rule: Dan Abramov, who popularised the pattern, later said not to split components this way dogmatically. Hooks let one component use data logic without a wrapper.
  • The modern form of the same split is Server Components that fetch, rendering Client Components that interact.
react.dev
09What React anti-patterns do you look for in a code review?Mid
SHORT ANSWER

Mostly state that should not be state, effects that should not be effects, and identity problems: unstable keys, components defined inside components, and objects recreated on every render where a stable reference is expected.

Anti-patternWhy it hurtsDo this instead
Mutating state or propsReact cannot see the change; memoisation and effects breakCreate a new object or array
Storing derived data in stateTwo sources of truth that drift apartCompute it during render
Copying props into stateThe copy ignores later prop changesUse the prop, or reset with a key
An effect that sets state from props or stateAn extra render with stale dataDerive it, or do it in the event handler
Index or random keys on dynamic listsState sticks to the wrong item, or everything remountsStable IDs from the data
Defining a component inside anotherA new component type each render; state resetsMove it to module level
Fetching in an effect with no cleanupRace conditions and updates after unmountAbort or ignore; better, a query library
Suppressing the exhaustive-deps lint ruleStale closuresRestructure so the dependency is not needed
State for values that are never renderedNeedless re-rendersA ref
A context holding everythingEvery consumer re-renders on any changeSplit it, or use a store
Boolean props multiplying (isCompact, hasIcon, ...)Untestable combinationsComposition, or a single variant union
Spreading unknown props onto a DOM elementInvalid attributes, accidental overridesPick the props you forward
memo, useMemo, useCallback everywhereNoise, and stale values when dependencies are wrongMeasure first; fix the structure

The trap: The older lists you will find online also mention componentWillMount, componentWillReceiveProps, string refs and findDOMNode. Those APIs are deprecated or removed, so they are history questions now, not review comments.

FOLLOW-UPS
Which of these causes the most bugs in practice?
Derived state kept in sync with effects. It is the root of most 'the UI shows the old value for a moment' reports, and removing the state usually deletes more code than it adds.
react.dev
10How do you handle forms and validation in React?Mid
SHORT ANSWER

For a small form, controlled inputs with validation in the submit handler, or uncontrolled inputs read from FormData. For anything larger, a form library (React Hook Form is the usual choice) with a schema (Zod) that produces both the validation and the TypeScript type. In React 19, form actions handle pending and error state without client-side plumbing.

React Hook Form with a Zod schema
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const schema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(12, "At least 12 characters"),
});
type FormValues = z.infer<typeof schema>;

export function SignInForm({ onSignIn }: { onSignIn: (values: FormValues) => Promise<void> }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ resolver: zodResolver(schema) });

  return (
    <form onSubmit={handleSubmit(onSignIn)} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" aria-invalid={Boolean(errors.email)} {...register("email")} />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" aria-invalid={Boolean(errors.password)} {...register("password")} />
      {errors.password && <p role="alert">{errors.password.message}</p>}

      <button disabled={isSubmitting}>Sign in</button>
    </form>
  );
}
  • Validate on the server too. Client-side validation is for the user's convenience; it protects nothing.
  • When to validate: on submit first, then on blur or on change for fields that already have an error. Validating on every keystroke from the start shouts at people mid-word.
  • Accessibility: every input has a label; errors are linked with aria-describedby and announced; focus moves to the first invalid field on submit.
  • Performance: controlled inputs re-render the form on each keystroke. React Hook Form registers uncontrolled inputs, so typing in one field does not re-render the others.
ApproachFits
Controlled useState per fieldTwo or three fields; live formatting; dependent fields
Uncontrolled + FormDataSimple submit-only forms; progressive enhancement
React Hook Form + schemaMany fields, field arrays, async validation
useActionState with a form actionServer-validated forms in a framework with Server Functions
FOLLOW-UPS
How do you prevent a double submit?
Disable the submit button while the request is pending (isSubmitting, useFormStatus().pending), and make the server operation idempotent, because disabling a button is not a guarantee.
How do you show errors that come back from the server?
Map them onto the fields (setError in React Hook Form, or the state returned by the action), and keep a form-level message for errors that belong to no field.
react.dev
11How do you structure a large React codebase?Senior
SHORT ANSWER

By feature, not by file type. Code that changes together lives together, each feature exposes a small public API, and dependencies point in one direction: features may use shared code, shared code never imports from a feature. The exact folder names matter far less than enforcing those boundaries.

A feature-based layout
// src/
//   app/                 routing, providers, global styles: wires features together
//   features/
//     checkout/
//       components/      used only inside this feature
//       hooks/
//       api.ts           the feature's requests and query keys
//       types.ts
//       index.ts         the public API: the only file other features may import
//     catalog/
//   shared/
//     ui/                design-system components: Button, Dialog, Input
//     lib/               framework-agnostic utilities
//     api/               the HTTP client, auth headers, error mapping

// features/checkout/index.ts
export { CheckoutPage } from "./components/CheckoutPage";
export { useCart } from "./hooks/useCart";
export type { CartItem } from "./types";
  • Colocation. A component's styles, tests and stories sit next to it. Deleting a feature is deleting a folder.
  • A public API per feature. Other code imports from features/checkout, never from deep inside it. A lint rule (no-restricted-imports, eslint-plugin-boundaries) makes that a build error rather than a convention.
  • One-way dependencies. app depends on features, which depend on shared. Two features that need each other usually share a concept that belongs in a third place.
  • Shared is earned. Code moves to shared when a second feature needs it, not in anticipation.

Named methodologies describe the same ideas at different granularity: Feature-Sliced Design formalises the layers (app, pages, widgets, features, entities, shared), and Atomic Design organises the UI library itself (atoms, molecules, organisms).

FOLLOW-UPS
What goes wrong with grouping by type (components/, hooks/, utils/)?
It scales with the number of files, not the number of features. One change touches five distant folders, nothing tells you what belongs together, and every folder becomes a place where anything can import anything.
Are barrel files a problem?
As a feature's public API, no. As a habit in every folder, they can pull unrelated modules into a bundle, slow down tooling and hide circular imports.
react.dev

Want reps on this? Drill State Management Taxonomy →

React

Server rendering and modern React

React 18 and 19, Suspense, Server Components and hydration. Interviewers use these to see whether your knowledge stopped at hooks or kept up.

10
01What is the difference between CSR, SSR, SSG and ISR?Mid
SHORT ANSWER

They differ in where and when the HTML is produced. Client-side rendering builds the page in the browser. Server-side rendering builds it on the server for each request. Static generation builds it once at build time. Incremental static regeneration serves a static page and rebuilds it in the background when it is stale.

HTML is generatedStrengthsWeaknessesFits
CSRIn the browser, after the JavaScript loadsSimple hosting; rich interactivityBlank until the bundle runs; weak SEODashboards, tools behind a login
SSROn the server, per requestFast first paint; fresh, personalised content; good SEOServer cost; a slow data source delays the responsePersonalised or frequently changing pages
SSGAt build timeFastest; served from a CDN; cheapStale until the next build; build time grows with page countDocs, blogs, marketing pages
ISRAt build time, then re-generated on a timer or on demandCDN speed with periodic freshnessSomeone sees the stale version; cache invalidation is yours to reason aboutProduct pages, news, large catalogues
  1. With SSR, SSG and ISR the browser receives real HTML and can paint immediately.
  2. It then downloads the JavaScript for the page.
  3. React hydrates: it attaches event handlers to the existing HTML. Only now is the page interactive.
  • Streaming SSR sends the HTML in chunks as each part becomes ready, so one slow query no longer holds back the whole page.
  • Server Components change what is sent: components that never need interactivity render on the server and ship no JavaScript at all.
  • Modern frameworks mix these per route, and even per component, so the real question is usually 'which parts of this page are static, which are per-request, and which are interactive'.

Interview line: I choose by asking two things about each page: does the content differ per user or per request, and how fresh must it be? Static when it can be, server-rendered when it must be, client-rendered for the parts that are purely interactive.

FOLLOW-UPS
SSR makes the page appear sooner. Does it make it interactive sooner?
Not by itself. The HTML shows early, but nothing responds until the JavaScript has loaded and hydration finishes. That gap is why shipping less client JavaScript matters as much as rendering on the server.
Why does CSR hurt SEO and performance metrics?
The initial HTML is an empty shell. Crawlers that do not run JavaScript see nothing, and the largest contentful paint cannot happen until the bundle has downloaded, parsed and fetched its data.
react.dev
02What is hydration? What causes a hydration mismatch, and how do you fix one?Mid
SHORT ANSWER

Hydration is React taking over HTML that was rendered on the server: it renders the components again in the browser, expects the output to match what is already in the DOM, and attaches event handlers to it. A mismatch happens when the server and the first client render produce different output.

Three ways to cause a mismatch
function Greeting() {
  // Each of these renders one thing on the server and another in the browser.
  const time = new Date().toLocaleTimeString(); // different clock, different locale
  const width = typeof window === "undefined" ? 0 : window.innerWidth; // no window on the server
  const id = Math.random(); // different every call

  return (
    <p data-id={id}>
      {time} at {width}px
    </p>
  );
}
CauseFix
Dates, times, time zones, locale formattingRender on the client only, or pass the formatted value from the server
typeof window, localStorage, matchMedia checks during renderRead them after hydration (an effect or useSyncExternalStore)
Math.random(), incrementing IDsuseId, or generate the value on the server and pass it down
Invalid HTML nesting (<div> inside <p>, <a> inside <a>)Fix the markup: the browser 'repairs' it and the DOM no longer matches
Browser extensions that modify the pageNothing to fix in your code; suppressHydrationWarning on the affected element if it is noisy
Render the same thing first, then upgrade
const subscribe = () => () => {};

// False during server rendering and hydration, true afterwards.
function useHydrated() {
  return useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  );
}

function LocalTime({ iso }: { iso: string }) {
  const hydrated = useHydrated();
  // The server and the first client render agree on the ISO string;
  // the localised version appears right after hydration.
  return <time dateTime={iso}>{hydrated ? new Date(iso).toLocaleTimeString() : iso}</time>;
}

The trap: suppressHydrationWarning hides the warning for one element's text and attributes; it does not repair anything. Use it for an unavoidable difference such as a timestamp, not to silence a real bug.

FOLLOW-UPS
What does React do when it finds a mismatch?
It logs an error showing the difference and falls back to client-rendering that subtree up to the nearest Suspense boundary, throwing away the server HTML there. You lose the benefit of server rendering for that part.
What is selective hydration?
With Suspense boundaries and streaming, React hydrates the page in pieces instead of all at once, and prioritises the piece the user is interacting with. Wrapping independent sections in Suspense is what makes it possible.
react.dev
03What is Suspense?Mid
SHORT ANSWER

<Suspense> lets a component declare that it is not ready to render yet, and lets a parent decide what to show in the meantime. While anything inside the boundary is waiting, for lazily loaded code or for data, React shows the fallback; when it is ready, React swaps in the real content.

declare function Comments(props: { postId: string }): ReactNode; // suspends while its data loads
declare function Post(props: { postId: string }): ReactNode;
declare function PostSkeleton(): ReactNode;
declare function CommentsSkeleton(): ReactNode;

function PostPage({ postId }: { postId: string }) {
  return (
    <Suspense fallback={<PostSkeleton />}>
      <Post postId={postId} />
      {/* A nested boundary: the post appears as soon as it is ready,
          without waiting for the comments. */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={postId} />
      </Suspense>
    </Suspense>
  );
}
What can suspendHow
CodeA component created with lazy
DataReading a promise with use, or a Suspense-enabled library or framework
Server renderingWith streaming, each boundary's HTML is sent when its content is ready
  • Loading UI becomes declarative. No isLoading flags threaded through props; the boundary's position decides what is replaced by a placeholder.
  • Boundaries are a design decision. Things that should appear together share a boundary; things that can pop in independently get their own.
  • Pair it with an error boundary. Suspense handles 'not yet'; an error boundary handles 'failed'.
  • Transitions avoid flashes. If an update inside a transition suspends, React keeps showing the current screen instead of dropping back to a fallback the user has already seen past.

The trap: A plain fetch inside useEffect does not trigger Suspense. The component has to suspend during render, which means use, lazy, or a library built for it.

FOLLOW-UPS
How does a component 'suspend'?
It throws a promise during render (that is what use and lazy do internally). React catches it at the nearest boundary, shows the fallback, and re-renders the component when the promise settles.
Where do you put boundaries?
Around each part that has its own loading time and can make sense by itself: the route, then major panels. Too few and the whole page waits for the slowest part; too many and the page fills with spinners.
react.dev
04What are React Server Components? How do they differ from Client Components, and from SSR?Senior
SHORT ANSWER

Server Components run only on the server, at build time or per request. They can be async, read databases and files directly, and none of their code is sent to the browser. Client Components, marked with "use client", are the ones that hold state, run effects and handle events. SSR is a different thing: it turns either kind into HTML for the first paint.

app/posts/page.tsx
import { LikeButton } from "./like-button";

declare const db: { post: { findMany(): Promise<{ id: string; title: string; likes: number }[]> } };

// A Server Component: async, talks to the database, ships no JavaScript.
export default async function PostsPage() {
  const posts = await db.post.findMany();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          <LikeButton postId={post.id} initialLikes={post.likes} />
        </li>
      ))}
    </ul>
  );
}
app/posts/like-button.tsx
"use client";

import { useState } from "react";

// A Client Component: interactive, so it is in the bundle and it hydrates.
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  return (
    <button data-post={postId} onClick={() => setLikes(likes + 1)}>
      {likes} likes
    </button>
  );
}
Server ComponentClient Component
RunsOn the server onlyOn the server for the initial HTML, then in the browser
JavaScript sent to the browserNoneYes
useState, useEffect, event handlersNoYes
async / await in the componentYesNo (read promises with use)
Direct access to a database, the file system, secretsYesNo
Browser APIsNoYes
May import the other kindYesNo, but it can receive Server Components as children
  • "use client" marks a boundary, not a single component: everything that file imports becomes part of the client bundle. Put it as far down the tree as you can.
  • Props passed from a Server Component to a Client Component cross the network, so they must be serialisable: plain data, promises and JSX are fine; functions are not, with the exception of Server Functions.
  • Server Components need a framework and bundler that support them, such as Next.js or React Router.

Interview line: RSC and SSR answer different questions. SSR is about the first response being HTML. RSC is about which components need to exist in the browser at all. Client Components are still server-rendered to HTML; Server Components are never hydrated.

FOLLOW-UPS
A Server Component needs something interactive deep inside it, and that interactive part needs to wrap more server content. How?
Pass the server content as children. The Client Component receives already-rendered output and places it; it never imports the Server Component, so that code stays on the server.
What are the benefits?
Smaller bundles, data fetching next to the data with no client-server waterfall, secrets that never leave the server, and less to hydrate. The costs are a more complex mental model and a dependency on a framework.
Can you use context in a Server Component?
No, context is a client feature. You render a Client Component provider near the root and pass server-rendered children through it.
react.dev
05What changed in React 18 and React 19?Mid
SHORT ANSWER

React 18 introduced concurrent rendering and the features built on it: automatic batching, transitions and streaming server rendering with Suspense. React 19 built on that with Actions for async mutations, the use hook, stable Server Components, ref as a regular prop, and a round of removals of long-deprecated APIs.

React 18What it gave you
createRoot / hydrateRootThe new root API that enables concurrent features
Automatic batchingUpdates in timeouts, promises and native handlers are grouped into one render
useTransition, useDeferredValueNon-urgent updates that do not block input
Suspense on the serverStreaming HTML and selective hydration
useId, useSyncExternalStore, useInsertionEffectSSR-safe IDs; safe external stores; a hook for style libraries
Stricter Strict ModeEffects run twice on mount in development
React 19What it gave you
ActionsAsync functions in transitions, with pending state, errors and form resets managed by React
useActionState, useFormStatus, useOptimisticForm state, pending status of a parent form, and optimistic UI
<form action={fn}>A function as a form action, receiving FormData
useRead a promise or a context during render, even conditionally
Server Components and Server FunctionsStable
ref as a propNo forwardRef needed for function components
<Context> as a provider<ThemeContext value={...}> instead of .Provider
Document metadata<title>, <meta> and <link> rendered anywhere are hoisted to <head>
Ref cleanup functionsA callback ref can return a cleanup
Better hydration errorsA single message with a diff of the mismatch
  • Removed in 19: ReactDOM.render and hydrate, findDOMNode, string refs, legacy context, propTypes and defaultProps on function components, and react-test-renderer/shallow.
  • Since 19: the React Compiler reached a stable release, and React 19.2 added <Activity> for hiding UI while keeping its state, and useEffectEvent.
FOLLOW-UPS
Which of these changes how you write everyday components?
Automatic batching and ref as a prop are invisible improvements. Actions with useActionState replace the hand-written loading, error and data state around a mutation. And the compiler removes most manual memoisation.
How would you upgrade an app from 17 or 18 to 19?
Go to the latest 18.x first and clear every deprecation warning, since 19 removes what 18 warned about. Run the official codemods, replace ReactDOM.render with createRoot, update the type packages, and test under Strict Mode.
react.dev
06What are Actions in React 19? What do useActionState and useFormStatus do?Senior
SHORT ANSWER

An Action is an async function run inside a transition, typically to perform a mutation. React tracks its pending state, sends thrown errors to the nearest error boundary, and for forms resets the fields on success. useActionState wraps an action and gives you its latest result and pending flag; useFormStatus lets a child of a form read whether that form is submitting.

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

declare function saveName(name: string): Promise<void>;

interface FormState {
  name: string;
  error: string | null;
}

// Receives the previous state and the submitted FormData; returns the next state.
async function updateName(previous: FormState, formData: FormData): Promise<FormState> {
  const name = String(formData.get("name") ?? "").trim();
  if (name === "") return { ...previous, error: "Name is required" };

  try {
    await saveName(name);
    return { name, error: null };
  } catch {
    return { ...previous, error: "Could not save. Try again." };
  }
}

function SubmitButton() {
  const { pending } = useFormStatus(); // status of the nearest parent <form>
  return <button disabled={pending}>{pending ? "Saving" : "Save"}</button>;
}

export function NameForm() {
  const [state, formAction] = useActionState(updateName, { name: "", error: null });

  return (
    <form action={formAction}>
      <input name="name" defaultValue={state.name} />
      <SubmitButton />
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
BeforeWith Actions
const [isLoading, setIsLoading] = useState(false)isPending from useActionState or useTransition
const [error, setError] = useState(null)Returned as part of the action's state
onSubmit + preventDefault + reading inputs<form action={fn}> receives FormData
Passing isSubmitting down to the buttonuseFormStatus() inside the button
Resetting the form by handAutomatic for uncontrolled fields on success
  • useActionState returns [state, action, isPending]. The third value is useful when the pending UI lives in the same component as the form.
  • useFormStatus only works in a component rendered inside the <form>, not in the component that renders the form.
  • The action can be a Server Function, in which case the form works before JavaScript has loaded.
FOLLOW-UPS
How is this different from calling an async function in onSubmit?
Functionally you can build the same thing. Actions make the pending and error handling part of React, keep the UI responsive because the update is a transition, sequence overlapping submissions, and integrate with Suspense, optimistic updates and server functions.
What happens to an error thrown inside an action?
It propagates to the nearest error boundary. For expected failures such as validation, return them as state so the form can show them inline.
react.dev
07What is an optimistic update? How does useOptimistic help?Senior
SHORT ANSWER

An optimistic update shows the expected result of an action immediately, before the server confirms it, and rolls back if the request fails. useOptimistic gives you a temporary copy of some state that you can update during an action; when the action finishes, React discards the temporary value and shows the real state again.

interface Message {
  id: string;
  text: string;
  pending?: boolean;
}

interface ThreadProps {
  messages: Message[]; // the real, server-confirmed list
  sendMessage: (text: string) => Promise<void>; // resolves once messages has been updated
}

export function Thread({ messages, sendMessage }: ThreadProps) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (current, text: string): Message[] => [
      ...current,
      { id: "pending-" + current.length, text, pending: true },
    ],
  );

  async function submit(formData: FormData) {
    const text = String(formData.get("text") ?? "");
    addOptimistic(text); // appears at once, greyed out
    await sendMessage(text); // when this settles, the optimistic entry is dropped
  }

  return (
    <>
      <ul>
        {optimisticMessages.map((message) => (
          <li key={message.id} style={{ opacity: message.pending ? 0.5 : 1 }}>
            {message.text}
          </li>
        ))}
      </ul>
      <form action={submit}>
        <input name="text" />
      </form>
    </>
  );
}
  1. The user acts. The UI updates immediately with the predicted result.
  2. The request goes out in the background.
  3. On success, the real data replaces the prediction, usually with no visible change.
  4. On failure, the prediction disappears and you tell the user what happened.
Good candidatesPoor candidates
Likes, reactions, followsPayments and anything financial
Adding a comment or messageOperations the server may reject for reasons the client cannot predict
Toggling a setting, marking as readDestructive actions where a silent rollback would confuse
Reordering a listResults that depend on server-generated data you cannot guess

The trap: The rollback is automatic; the explanation is not. If the request fails and the item simply vanishes, the user assumes a bug. Always pair the rollback with a visible error and a way to retry.

FOLLOW-UPS
How was this done before useOptimistic?
By hand: update local state, fire the request, and restore the previous value in the catch. Or with a query library: write the predicted value into the cache in onMutate, restore it in onError, and refetch in onSettled.
What makes optimistic updates hard?
Concurrent actions on the same item, temporary IDs that have to be swapped for server IDs, and keeping the prediction logic identical to what the server will do.
react.dev
08What is the use hook, and how is it different from fetching in useEffect?Senior
SHORT ANSWER

use reads the value of a promise or a context during render. Given a pending promise it suspends the component, so the nearest <Suspense> shows its fallback, and when the promise resolves the component renders with the value. There is no loading state to manage, and unlike other hooks it can be called inside conditions and loops.

interface Comment {
  id: string;
  body: string;
}

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise); // suspends until resolved; throws to the error boundary if rejected

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.body}</li>
      ))}
    </ul>
  );
}

export function Post({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  return (
    <article>
      <h1>The post renders immediately</h1>
      <Suspense fallback={<p>Loading comments</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}
useEffect + fetchuse + Suspense
Request startsAfter the component has rendered and committedBefore or during render, wherever the promise was created
Loading stateYours to manageThe Suspense fallback
ErrorsYours to manageThe nearest error boundary
Server renderingDoes not runStreams the result with the HTML
Race conditionsYours to preventThe component renders with the promise it was given

The trap: The promise must be stable across renders. Creating it inside the component that calls use produces a new promise on every render and suspends forever. Create it in a Server Component, a router loader or a cache, and pass it down.

  • use(SomeContext) reads context like useContext, but may appear after an early return or inside an if.
  • It cannot be called inside a try/catch block; errors are handled by error boundaries.
FOLLOW-UPS
Why pass a promise as a prop instead of awaiting it in the parent?
Awaiting in the parent blocks the parent's render until the data arrives. Passing the promise lets the parent render and stream immediately, while only the child that needs the data waits.
react.dev
09What are Server Functions (Server Actions), and what must you be careful about?Senior
SHORT ANSWER

A Server Function is an async function marked with "use server" that runs on the server but can be called from client code as if it were local. The framework turns the call into a network request. Used as a form action or inside a transition it is called a Server Action. The danger is forgetting that it is a public HTTP endpoint.

actions.ts
"use server";

declare function getSession(): Promise<{ userId: string } | null>;
declare const db: {
  post: {
    findOwner(id: string): Promise<string | null>;
    delete(id: string): Promise<void>;
  };
};

export async function deletePost(postId: string) {
  // 1. Authenticate: who is calling?
  const session = await getSession();
  if (!session) throw new Error("Not signed in");

  // 2. Validate: arguments come from the network, whatever the types say.
  if (typeof postId !== "string" || postId === "") throw new Error("Invalid post id");

  // 3. Authorise: may this user do this to this record?
  const ownerId = await db.post.findOwner(postId);
  if (ownerId !== session.userId) throw new Error("Forbidden");

  await db.post.delete(postId);
}
delete-button.tsx
"use client";

import { useTransition } from "react";
import { deletePost } from "./actions";

export function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button disabled={isPending} onClick={() => startTransition(() => deletePost(postId))}>
      Delete
    </button>
  );
}
  • It is reachable by anyone. A direct POST can call it with any arguments, regardless of whether your UI shows the button. Check authentication and authorisation inside every function.
  • TypeScript types are not validation. The argument typed string can be anything at runtime. Parse it with a schema.
  • Do not trust what the client sends back. Hidden form fields and values bound from a previous render can be tampered with.
  • Return only what the client may see. The return value is serialised to the browser.

On the positive side: there is no API route to write, the types flow end to end, and a <form action={serverFunction}> works before the page's JavaScript has loaded.

FOLLOW-UPS
When would you still write a regular API endpoint?
When something other than your own React app calls it: a mobile client, a webhook, a public API. Server Functions are an implementation detail of one app, with no stable URL or contract.
react.dev
10What does Next.js add on top of React? How do the Pages Router and App Router differ?Mid
SHORT ANSWER

Next.js is a framework around React: file-based routing, server rendering and static generation, data fetching and caching, code splitting, image and font optimisation, and API endpoints. The Pages Router is the original model, built on per-page data functions. The App Router is built on Server Components, nested layouts and streaming.

Pages Router (pages/)App Router (app/)
getStaticPropsAn async Server Component whose data is cached
getServerSidePropsAn async Server Component that reads request data or fetches uncached
getStaticPathsgenerateStaticParams
revalidate returned from getStaticPropsRevalidation by time or by tag
pages/api/*Route Handlers (route.ts)
_app.tsx, _document.tsxNested layout.tsx files
Every component is a Client ComponentServer Components by default; "use client" to opt in
useRouter from next/routeruseRouter, usePathname, useSearchParams from next/navigation
app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

declare function getPost(slug: string): Promise<{ title: string; body: string } | null>;
declare function getAllSlugs(): Promise<string[]>;

// Prerender one page per post at build time.
export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params; // params is a promise in current versions
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
  • Special files define UI for a route segment: page, layout, loading (a Suspense fallback), error (an error boundary), not-found.
  • A route is static unless it uses something only known at request time, such as cookies, headers or uncached data.
  • Mutations go through Server Functions; next/link prefetches routes; next/image resizes and lazy-loads images.

The trap: Next.js changes quickly between major versions: caching defaults, async request APIs, the rename of Middleware to Proxy. In an interview, say which version you have used, and reason from the model (what is static, what is per-request, what runs on the client) instead of reciting an API from memory.

FOLLOW-UPS
When would you not use a framework?
For an app that is entirely behind a login, has no SEO needs and is hosted as static files, a client-only Vite app is simpler to build and operate.
What are the alternatives?
React Router in framework mode (the successor to Remix), TanStack Start, and Astro for content-heavy sites. They make different choices about caching and where the server runs, on the same React foundations.
nextjs.org

Want reps on this? Drill App Router & RSC →

React

Routing, testing and production concerns

Everything around the components: getting between pages, proving the code works, and keeping it secure and usable. Real projects are mostly this, and interviewers know it.

10
01How does client-side routing work? Walk through React Router's main pieces.Mid
SHORT ANSWER

A client-side router intercepts navigation, updates the URL with the History API without a page reload, and renders the components that match the new URL. React Router gives you route definitions, links, nested layouts, and hooks to read params and navigate in code.

React Router
import {
  BrowserRouter,
  Link,
  NavLink,
  Outlet,
  Route,
  Routes,
  useNavigate,
  useParams,
  useSearchParams,
} from "react-router-dom";

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="users/:userId" element={<UserPage />} />
          <Route path="*" element={<NotFound />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

function Layout() {
  return (
    <>
      <nav>
        <NavLink to="/" className={({ isActive }) => (isActive ? "active" : "")}>
          Home
        </NavLink>
      </nav>
      <Outlet /> {/* the matched child route renders here */}
    </>
  );
}

function UserPage() {
  const { userId } = useParams(); // from the path: /users/42
  const [searchParams, setSearchParams] = useSearchParams(); // from the query: ?tab=posts
  const navigate = useNavigate();
  const tab = searchParams.get("tab") ?? "profile";

  return (
    <>
      <h1>
        User {userId}, {tab}
      </h1>
      <button onClick={() => setSearchParams({ tab: "posts" })}>Posts</button>
      <button onClick={() => navigate("/", { replace: true })}>Back home</button>
    </>
  );
}

function Home() {
  return <Link to="/users/42?tab=posts">Open user 42</Link>;
}

function NotFound() {
  return <h1>Page not found</h1>;
}
PiecePurpose
<BrowserRouter> / createBrowserRouterConnects the app to the browser's URL and history
<Route path element>Maps a URL pattern to a component; nesting gives nested layouts
<Outlet>Where a parent route renders its matched child
<Link>, <NavLink>Navigation without a reload; NavLink knows whether it is active
useParams, useSearchParamsRead dynamic segments and the query string
useNavigate, <Navigate>Navigate from code, or by rendering
path="*"The catch-all for a 404 page
  • BrowserRouter uses real paths (/users/42) through the History API, so the server must return the app for every route. HashRouter keeps the route after a #, which works on any static host but gives uglier URLs and is not sent to the server.
  • navigate(to) pushes a history entry; navigate(to, { replace: true }) replaces the current one, so Back does not return to it.
  • Data routers (createBrowserRouter) add loaders and actions, so data is fetched in parallel with the route's code instead of after it renders.
FOLLOW-UPS
How is this different from traditional server routing?
Traditionally every navigation is a request for a new HTML document. A client router keeps one document, swaps components, and fetches only data. Transitions are faster and state survives, but the first load is heavier and you take on scroll restoration, focus management and loading states.
How do you pass data from one route to another?
Through the URL when it should survive a refresh or be shareable: path params and search params. Through navigate(to, { state }) for transient data such as 'where did you come from'. Through a shared store or cache for anything larger.
How do you code-split by route?
Wrap route components in lazy with a Suspense boundary, or use the router's lazy route option, which also loads the route's loader in parallel.
reactrouter.com
02How do you implement protected routes and redirect back after login?Mid
SHORT ANSWER

Wrap the private routes in a layout route that checks the auth state. If there is no user, it renders a redirect to the login page and records where the user was heading. After a successful login, navigate to that saved location with replace, so the login page does not stay in the history.

A guard as a layout route
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";

type Auth =
  | { status: "loading" }
  | { status: "anonymous" }
  | { status: "authenticated"; user: { name: string } };

declare function useAuth(): Auth & { signIn(email: string, password: string): Promise<void> };

export function RequireAuth() {
  const auth = useAuth();
  const location = useLocation();

  if (auth.status === "loading") return <p>Checking your session</p>; // do not redirect yet
  if (auth.status === "anonymous") {
    return <Navigate to="/login" replace state={{ from: location.pathname }} />;
  }
  return <Outlet />;
}

export function LoginPage() {
  const auth = useAuth();
  const navigate = useNavigate();
  const location = useLocation();

  async function handleLogin(formData: FormData) {
    await auth.signIn(String(formData.get("email")), String(formData.get("password")));

    const from: unknown = location.state?.from;
    // Only follow a path inside this app: never a full URL supplied from outside.
    const target = typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/";
    navigate(target, { replace: true });
  }

  return (
    <form action={handleLogin}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <button>Sign in</button>
    </form>
  );
}

// <Route element={<RequireAuth />}>
//   <Route path="/dashboard" element={<Dashboard />} />
//   <Route path="/settings" element={<Settings />} />
// </Route>
  • Handle the loading state explicitly. On a refresh the session is unknown for a moment; redirecting during that moment bounces a signed-in user to the login page.
  • replace on both redirects keeps Back from trapping the user between the guard and the login page.
  • Validate the return path. A returnTo parameter that accepts any URL is an open redirect, which phishing relies on.

The trap: A route guard is user experience, not security. All the JavaScript for the protected page is already in the user's browser, and anyone can call the API directly. The server must check authentication and authorisation on every request.

FOLLOW-UPS
How do you handle role-based access?
The same guard with a role check, rendering a 'not authorised' page instead of a redirect when the user is signed in but lacks permission. Hide navigation the user cannot use, and still enforce everything on the server.
reactrouter.com
03How do you handle authentication in a single-page app? Where do you store the token?Senior
SHORT ANSWER

Prefer a session held in an HttpOnly, Secure, SameSite cookie, which JavaScript cannot read and so an XSS bug cannot steal. On load, ask the server who the user is and keep the answer in memory. Tokens in localStorage are convenient and readable by any script on the page.

StorageSurvives refreshReadable by JavaScript (XSS)Sent automatically (CSRF)Verdict
localStorage / sessionStorageYesYesNoAvoid for anything that grants access
In memoryNoOnly while the page is openNoGood for a short-lived access token, with a refresh cookie
HttpOnly cookieYesNoYesRecommended; add SameSite and CSRF protection for mutations
interface User {
  id: string;
  name: string;
}

type AuthState =
  | { status: "loading" }
  | { status: "anonymous" }
  | { status: "authenticated"; user: User };

const AuthContext = createContext<AuthState>({ status: "loading" });

export function AuthProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<AuthState>({ status: "loading" });

  useEffect(() => {
    let ignore = false;

    // The browser attaches the session cookie; the app never sees the token.
    fetch("/api/me", { credentials: "include" })
      .then((res) => (res.ok ? (res.json() as Promise<User>) : null))
      .then((user) => {
        if (ignore) return;
        setState(user ? { status: "authenticated", user } : { status: "anonymous" });
      })
      .catch(() => {
        if (!ignore) setState({ status: "anonymous" });
      });

    return () => {
      ignore = true;
    };
  }, []);

  return <AuthContext value={state}>{children}</AuthContext>;
}

export const useAuthState = () => useContext(AuthContext);
  • Short-lived access, long-lived refresh. The access credential expires in minutes; a refresh credential in an HttpOnly cookie renews it silently.
  • Handle 401 centrally. One place in the HTTP client attempts a refresh, retries the request once, and signs the user out if that fails.
  • Sign out everywhere. Clear server-side session state, and notify other tabs with a storage event or BroadcastChannel.
  • The three states matter. Rendering as if anonymous while the session is still being checked causes a flash of the login screen on every refresh.
FOLLOW-UPS
If cookies are sent automatically, what stops CSRF?
SameSite=Lax or Strict stops the browser attaching the cookie to cross-site requests, which covers most cases. For anything that changes state, add a CSRF token or check the Origin header, and never mutate on a GET.
With server rendering, where does the check happen?
On the server, per request: read the session cookie, and redirect before any HTML is produced. The client never renders a protected page for an anonymous user at all.
developer.mozilla.org
04What are the main security concerns in a React app? How does React help with XSS?Senior
SHORT ANSWER

Cross-site scripting is the main one. React escapes every value you render with {}, so user content is displayed as text and not executed. The protection ends wherever you bypass it: dangerouslySetInnerHTML, user-controlled URLs, and anything that writes to the DOM directly.

The three places XSS gets in
import DOMPurify from "dompurify";

export function Comment({ text, html, website }: { text: string; html: string; website: string }) {
  let safeHref: string | undefined;
  try {
    const url = new URL(website);
    if (url.protocol === "http:" || url.protocol === "https:") safeHref = url.href;
  } catch {
    safeHref = undefined; // not a valid absolute URL
  }

  return (
    <article>
      {/* Safe: rendered as text, even if it contains <script> or an onerror attribute. */}
      <p>{text}</p>

      {/* Dangerous by design. Only ever pass HTML you have sanitised. */}
      <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />

      {/* A user-supplied href can be "javascript:...". Allow known protocols only. */}
      {safeHref && (
        <a href={safeHref} rel="noopener noreferrer" target="_blank">
          Website
        </a>
      )}
    </article>
  );
}
ThreatDefence
XSS through injected HTMLAvoid dangerouslySetInnerHTML; sanitise with DOMPurify when rich text is required
XSS through URLsValidate the protocol of any user-supplied href or src
XSS in generalA Content Security Policy that blocks inline scripts and unknown origins
Token theftKeep credentials in HttpOnly cookies, out of reach of scripts
CSRFSameSite cookies, CSRF tokens, checking Origin on mutations
Leaked secretsAnything in the client bundle is public; keep API keys on the server
Supply chainLock files, audits, minimal dependencies, Subresource Integrity for third-party scripts
Server Functions and API routesAuthenticate, authorise and validate inside every one

Interview line: The client is not a security boundary. Validation and permission checks in React exist for the user's convenience; the server has to repeat every one of them.

FOLLOW-UPS
Why is it called dangerouslySetInnerHTML, and why the __html object?
The awkward name and shape are deliberate friction: you cannot use it by accident, and it stands out in code review and in a search of the codebase.
Is an environment variable in a React app secret?
Not if it is exposed to the browser. Variables with a public prefix (NEXT_PUBLIC_, VITE_) are inlined into the bundle at build time and readable by anyone.
react.dev
05How do you test React components?Mid
SHORT ANSWER

With React Testing Library on top of a runner such as Vitest or Jest. You render the component, find elements the way a user would (by role, label and text), interact with them, and assert on what is displayed. You test behaviour, not implementation, so the tests survive refactoring.

SignupForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { SignupForm } from "./SignupForm";

test("submits the email the user typed", async () => {
  const user = userEvent.setup();
  const onSignup = vi.fn();
  render(<SignupForm onSignup={onSignup} />);

  await user.type(screen.getByLabelText(/email/i), "ada@example.com");
  await user.click(screen.getByRole("button", { name: /sign up/i }));

  expect(onSignup).toHaveBeenCalledWith("ada@example.com");
});

test("shows an error for an invalid email", async () => {
  const user = userEvent.setup();
  render(<SignupForm onSignup={vi.fn()} />);

  await user.type(screen.getByLabelText(/email/i), "not-an-email");
  await user.click(screen.getByRole("button", { name: /sign up/i }));

  expect(screen.getByRole("alert")).toHaveTextContent(/valid email/i);
});
QueryReturns when missingUse for
getBy...ThrowsElements that should be there now
queryBy...nullAsserting that something is not there
findBy...Rejects after a timeoutElements that appear asynchronously
  • Query priority: getByRole first, then getByLabelText, getByPlaceholderText, getByText; getByTestId only as a last resort. If you cannot find an element by role or label, a screen reader user cannot either.
  • userEvent over fireEvent: it simulates the full sequence of events a real interaction produces (focus, key down, input, key up).
  • What to test: what the user sees and can do. Not state variables, not which hooks were called, not component internals.
LayerToolProportion
StaticTypeScript, ESLintAlways on
UnitVitest / Jest for pure functions, reducers, hooksSome
IntegrationTesting Library: several components working togetherMost
End to endPlaywright or Cypress against a running appA few critical journeys
FOLLOW-UPS
Why not test implementation details?
Such tests fail when you refactor without changing behaviour, and pass when behaviour breaks in a way they did not look at. They cost maintenance and give false confidence.
What were shallow rendering and Enzyme?
Enzyme rendered a component one level deep and let tests reach into its state and instance. It encouraged implementation-detail tests and was never updated for modern React. react-test-renderer is deprecated as of React 19. Testing Library is the standard.
What is snapshot testing, and is it useful?
It serialises rendered output to a file and fails when the output changes. It is cheap to write and catches unintended changes, but large snapshots are approved without being read. Keep them small and prefer explicit assertions.
testing-library.com
06How do you test asynchronous behaviour, API calls, hooks, and components that need providers?Mid
SHORT ANSWER

Await findBy queries for things that appear later. Mock the network at the HTTP level with Mock Service Worker, so the component's real fetching code runs. Test a custom hook through renderHook. And wrap the component in the providers it needs with a small custom render function.

Async UI with a mocked network
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { Profile } from "./Profile";

const server = setupServer(
  http.get("/api/user", () => HttpResponse.json({ name: "Ada" })),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("shows the user once loaded", async () => {
  render(<Profile />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  expect(await screen.findByRole("heading", { name: "Ada" })).toBeInTheDocument();
});

test("shows an error when the request fails", async () => {
  server.use(http.get("/api/user", () => new HttpResponse(null, { status: 500 })));
  render(<Profile />);

  expect(await screen.findByRole("alert")).toHaveTextContent(/something went wrong/i);
});
Hooks and providers
import { act, render, renderHook } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { expect, test } from "vitest";
import { ThemeProvider } from "./theme";
import { useCounter } from "./useCounter";

test("increments", () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => result.current.increment()); // state updates must be wrapped in act

  expect(result.current.count).toBe(6);
});

// One place that knows which providers the app needs.
function renderWithProviders(ui: ReactElement) {
  const wrapper = ({ children }: { children: ReactNode }) => (
    <ThemeProvider theme="dark">{children}</ThemeProvider>
  );
  return render(ui, { wrapper });
}
SituationApproach
Content appears after a requestawait screen.findBy...
Something should disappearawait waitForElementToBeRemoved(...)
Debounce, timersFake timers, advanced explicitly
HTTPMSW handlers; override per test for error cases
Context, router, store, query clientA wrapper with real providers and test data
A third-party module you cannot runvi.mock / jest.mock that one module

The trap: Mocking your own fetchUser function means the test never exercises the URL, the parsing or the error handling. Intercepting at the network layer tests all of it, and the same handlers can power local development.

FOLLOW-UPS
What is the act warning?
It means a state update happened in your test outside of act, usually because something asynchronous finished after the test stopped waiting. The fix is to await the resulting change with findBy or waitFor, not to wrap things in act until the warning stops.
How do you test a component connected to Redux?
Render it inside a real <Provider> with a real store created for that test and preloaded with the state you need. Mock stores test that an action was dispatched, which is an implementation detail.
testing-library.com
07How do you make a React application accessible?Mid
SHORT ANSWER

Mostly by using the right HTML: real buttons, links, labels, headings and landmarks give you keyboard support and screen reader semantics for free. Then manage focus where React changes the page without a navigation, and reach for ARIA only to describe what HTML cannot.

The most common mistake
function Toolbar({ onSave }: { onSave: () => void }) {
  return (
    <>
      {/* Not focusable, no keyboard activation, announced as nothing. */}
      <div className="btn" onClick={onSave}>
        Save
      </div>

      {/* Focusable, activates with Enter and Space, announced as "Save, button". */}
      <button type="button" onClick={onSave}>
        Save
      </button>
    </>
  );
}
function EmailField({ error }: { error?: string }) {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input
        id={id}
        type="email"
        aria-invalid={error !== undefined}
        aria-describedby={error ? id + "-error" : undefined}
      />
      {error && (
        <p id={id + "-error"} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}
  • Semantics: one <h1>, headings in order, <nav>, <main>, lists as lists, tables as tables. A fragment avoids wrapper divs that would break them.
  • Keyboard: everything reachable with Tab, operable with Enter or Space, with a visible focus ring. Never remove outlines without replacing them.
  • Focus management: move focus into a dialog when it opens and back to the trigger when it closes; move focus to the new page's heading on client-side navigation.
  • Dynamic updates: announce asynchronous results with a live region (role="status" or aria-live).
  • Images: meaningful alt text; alt="" for decoration.
  • Motion and colour: honour prefers-reduced-motion; keep sufficient contrast; never signal with colour alone.
ToolCatches
eslint-plugin-jsx-a11yMissing labels and alt text, invalid ARIA, click handlers on non-interactive elements
Testing Library role queriesWhether elements are exposed with the right role and name
axe (browser extension, jest-axe, Playwright)Automated audit of rendered pages
A keyboard and a screen readerEverything the automated tools miss, which is most of it
FOLLOW-UPS
What is the first rule of ARIA?
Do not use ARIA if a native element does the job. ARIA changes what is announced, not how the element behaves: role="button" on a div does not make it focusable or respond to the keyboard.
What does a client-side router break for accessibility?
A full page load moves focus to the top and announces the new title. A client-side transition does neither unless you do it: update document.title, and move focus or announce the change.
w3.org
08How do you internationalise a React app?Mid
SHORT ANSWER

Keep every user-facing string out of the components, in message catalogues keyed by ID, one per locale. Use a library that implements ICU MessageFormat for plurals and interpolation, and the browser's Intl APIs for dates, numbers and currencies. Never build sentences by concatenating strings.

With react-intl (FormatJS)
import { FormattedDate, FormattedMessage, FormattedNumber, useIntl } from "react-intl";

export function CartSummary({ count, total, updatedAt }: { count: number; total: number; updatedAt: Date }) {
  const intl = useIntl();

  return (
    <section>
      {/* Declarative: a component for text that renders as an element. */}
      <FormattedMessage
        id="cart.items"
        defaultMessage="{count, plural, =0 {Your cart is empty} one {# item} other {# items}}"
        values={{ count }}
      />
      <FormattedNumber value={total} style="currency" currency="EUR" />
      <FormattedDate value={updatedAt} dateStyle="medium" />

      {/* Imperative: for attributes, which need a plain string. */}
      <input
        placeholder={intl.formatMessage({ id: "cart.coupon", defaultMessage: "Coupon code" })}
        aria-label={intl.formatMessage({ id: "cart.coupon", defaultMessage: "Coupon code" })}
      />
      <small>{intl.locale}</small>
    </section>
  );
}
// The platform does the formatting; no library needed for this part.
const price = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5);
const date = new Intl.DateTimeFormat("pt-BR", { dateStyle: "long" }).format(new Date(2026, 0, 31));
const ago = new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-1, "day"); // "yesterday"
const rule = new Intl.PluralRules("ru").select(3); // "few": not every language has just one/other
  • Plurals and gender vary by language, which is why you use ICU messages and not count === 1 ? ... : ....
  • Word order changes too, so translate whole sentences with placeholders, never fragments glued together.
  • Layout: text expands (German is often a third longer than English), and right-to-left languages need dir="rtl" and logical CSS properties (margin-inline-start).
  • Loading: ship only the active locale's catalogue; load others on demand.
  • Locale source: the URL (/pt/...) is best for SEO and sharing; fall back to a saved preference, then Accept-Language.

The common libraries are react-intl (FormatJS), react-i18next, Lingui, and next-intl for Next.js. They differ in API, not in the underlying approach.

FOLLOW-UPS
What are the two ways of formatting in react-intl?
Components (<FormattedMessage>, <FormattedDate>) for text rendered as elements, and the imperative intl object from useIntl() (formatMessage, formatDate) for anywhere a string is required: placeholders, aria-label, document.title.
How do you catch untranslated strings?
Lint against string literals in JSX, extract message IDs in CI and compare against each catalogue, and use pseudo-localisation in development to make hard-coded text and truncation visible.
formatjs.github.io
09How do you handle loading and error states consistently across an app?Senior
SHORT ANSWER

Decide it once at the architecture level, not per component. Every asynchronous view has four states: loading, error, empty and success. Use Suspense boundaries for loading and error boundaries for failures at the route and widget level, give each error a way to retry, and report everything unexpected to a monitoring service.

One wrapper, used for every independent region
import { Suspense, type ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";

declare function reportError(error: unknown, info: { componentStack?: string | null }): void;

function Widget({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section aria-label={title}>
      <ErrorBoundary
        onError={reportError}
        fallbackRender={({ resetErrorBoundary }) => (
          <div role="alert">
            <p>{title} could not be loaded.</p>
            <button onClick={resetErrorBoundary}>Try again</button>
          </div>
        )}
      >
        <Suspense fallback={<p aria-busy="true">Loading {title}</p>}>{children}</Suspense>
      </ErrorBoundary>
    </section>
  );
}
Kind of failureWhere it is handledWhat the user sees
Render error in a widgetThe widget's error boundaryThat region's fallback; the rest of the page works
Failed data requestThe query's error state, or the boundaryInline message with Retry
Failed mutationThe event handler or actionInline message or toast; the form keeps its values
ValidationThe formMessages next to the fields
Not found, not authorisedThe routerA dedicated page
Anything unhandledRoot boundary, window.onerror, unhandledrejectionA generic fallback, and a report sent
  • Empty is not an error. Zero results needs its own design, usually with a suggestion for what to do next.
  • Skeletons over spinners for content regions: they reserve the layout and avoid shift.
  • Do not swallow errors. An empty catch hides the failure from the user and from you.
  • Report with context: the component stack, the route, the release version and the user's last actions.
FOLLOW-UPS
What does React 19 add for error reporting?
createRoot accepts onCaughtError, onUncaughtError and onRecoverableError callbacks, giving one place to send errors to monitoring, whether or not a boundary caught them.
Toast or inline message?
Inline when the error relates to something on screen that the user must fix or retry. A toast for the outcome of a background action. A toast alone is easy to miss and is gone when the user looks for it.
react.dev
10How do you debug a React application?Junior
SHORT ANSWER

React DevTools for the component tree: inspect and edit props, state and hooks, and see what rendered and why. The browser's own tools for everything else: breakpoints in the Sources panel, the Network tab for requests, the Performance panel for long tasks.

ToolUse it to
DevTools: Components tabInspect props, state, hooks and context for any component; edit them live; jump to source
DevTools: ProfilerRecord an interaction; see each commit, which components rendered, how long, and why
Highlight updatesWatch which parts of the screen re-render as you interact
Breakpoints and debuggerStep through a handler or an effect with real values
Network tabCheck request order, duplicates, payloads, waterfalls and cache behaviour
Strict ModeSurface impure renders and missing effect cleanups early
Error overlay and component stacksSee which component threw and what rendered it
  1. Reproduce it reliably, and reduce it to the smallest case.
  2. Locate it: is the data wrong (network, state), or is the rendering of correct data wrong?
  3. Inspect the component's actual props and state in DevTools instead of guessing.
  4. Explain the bug before fixing it. A fix you cannot explain is a coincidence.
  5. Add a test that fails without the fix.

The trap: console.log(state) right after a setter shows the old value, which sends people hunting for a bug that is not there. Log during render, or inspect the component in DevTools.

FOLLOW-UPS
A component renders with the wrong data. What do you check first?
Where the data comes from. In DevTools, look at the component's props and state, then walk up to the owner that supplied them. Most often the state was mutated, a key is wrong, or an effect holds a stale closure.
react.dev

Want reps on this? Drill Frontend System Design →

React

Class components and legacy APIs

You will not write these in new code, but plenty of production codebases still contain them, and plenty of interviewers still ask. Know them well enough to read, maintain and migrate.

6
01What are the lifecycle methods of a class component, and in what order do they run?Mid
SHORT ANSWER

A class component goes through mounting, updating and unmounting. The three you use constantly are componentDidMount (set up), componentDidUpdate (react to changed props or state) and componentWillUnmount (clean up). The rest exist for optimisation and rare edge cases.

PhaseMethods, in order
Mountingconstructor → static getDerivedStateFromProps → render → componentDidMount
Updatingstatic getDerivedStateFromProps → shouldComponentUpdate → render → getSnapshotBeforeUpdate → componentDidUpdate
UnmountingcomponentWillUnmount
Error handlingstatic getDerivedStateFromError → componentDidCatch
import { Component } from "react";

interface User {
  id: string;
  name: string;
}
interface Props {
  userId: string;
}
interface State {
  user: User | null;
}

class UserProfile extends Component<Props, State> {
  state: State = { user: null };
  private controller: AbortController | null = null;

  componentDidMount() {
    this.load(); // the DOM exists: start requests and subscriptions here
  }

  componentDidUpdate(prevProps: Props) {
    if (prevProps.userId !== this.props.userId) this.load(); // always compare, or it loops forever
  }

  componentWillUnmount() {
    this.controller?.abort(); // cancel what is still in flight
  }

  private load() {
    this.controller?.abort();
    this.controller = new AbortController();

    fetch("/api/users/" + this.props.userId, { signal: this.controller.signal })
      .then((res) => res.json() as Promise<User>)
      .then((user) => this.setState({ user }))
      .catch(() => {});
  }

  render() {
    return <h1>{this.state.user?.name ?? "Loading"}</h1>;
  }
}
MethodPurpose
shouldComponentUpdate(nextProps, nextState)Return false to skip a re-render. PureComponent implements it as a shallow comparison.
getDerivedStateFromProps(props, state)Compute state from props before every render. Rarely the right tool.
getSnapshotBeforeUpdate(prevProps, prevState)Read the DOM just before it changes (a scroll position) and hand the value to componentDidUpdate.
componentDidCatch(error, info)Log an error thrown by a descendant. With getDerivedStateFromError, this makes an error boundary.

The trap: componentWillMount, componentWillReceiveProps and componentWillUpdate were deprecated in 16.3 because they run in the render phase, which can be invoked more than once. They survive only under an UNSAFE_ prefix. If asked where to fetch data, the answer was always componentDidMount, never componentWillMount.

FOLLOW-UPS
How do these map to hooks?
componentDidMount, componentDidUpdate and componentWillUnmount collapse into one useEffect with a cleanup and a dependency array. shouldComponentUpdate becomes React.memo. getDerivedStateFromProps becomes computing during render. The snapshot and error methods have no hook equivalent.
Why is the constructor called only once?
Because the instance is created once, on mount. Updates call render on the same instance. That is why state initialised from props in the constructor does not follow later prop changes.
react.dev
02How does this.setState work in a class? Why pass it a function?Mid
SHORT ANSWER

setState shallow-merges the object you give it into the current state and schedules a re-render. Updates are batched, so this.state is not updated immediately. When the next state depends on the previous one, pass a function, which receives the up-to-date state and props.

import { Component } from "react";

class Counter extends Component<{ step: number }, { count: number; label: string }> {
  state = { count: 0, label: "Clicks" };

  addTwiceWrong = () => {
    // Both read the same this.state.count, so the result is +1.
    this.setState({ count: this.state.count + this.props.step });
    this.setState({ count: this.state.count + this.props.step });
  };

  addTwice = () => {
    // Each updater receives the result of the previous one: +2.
    this.setState((state, props) => ({ count: state.count + props.step }));
    this.setState((state, props) => ({ count: state.count + props.step }));
  };

  save = () => {
    // The optional second argument runs after the update has been applied and rendered.
    this.setState({ label: "Saved" }, () => console.log(this.state.label));
  };

  render() {
    // label is untouched by the count updates: setState merges.
    return (
      <button onClick={this.addTwice}>
        {this.state.label}: {this.state.count}
      </button>
    );
  }
}
Class setStateuseState setter
Partial updateMerged into the existing state (shallow)Replaces the value
Updater function(state, props) => partialState(previous) => next
After-update callbackSecond argumentNone; use an effect
Same valueStill re-renders (unless PureComponent)Bails out
  • Never assign this.state.count = 1. It changes the object without telling React, and a later setState may overwrite it. The only place to assign this.state is the constructor or a class field.
  • The merge is shallow: setState({ user: { name } }) replaces the whole user object.
  • forceUpdate() re-renders without a state change. Needing it is a sign that something that should be state is not.
FOLLOW-UPS
What happens if you call setState in the constructor?
Nothing useful: the component is not mounted yet, and React warns. Initialise this.state directly there.
Why should you not call setState in componentWillUnmount?
The component is about to be destroyed and will never render again, so the update is meaningless.
react.dev
03Why did event handlers in class components need .bind(this)?Junior
SHORT ANSWER

Because a method loses its this when it is passed around as a plain function. onClick={this.handleClick} hands React the function without its object, and since class bodies run in strict mode, this inside it is undefined when React calls it.

import { Component } from "react";

class Toggle extends Component<{ label: string }, { on: boolean }> {
  state = { on: false };

  constructor(props: { label: string }) {
    super(props); // must come first: 'this' does not exist until super() has run
    this.handleBound = this.handleBound.bind(this); // fix 1: bind once in the constructor
  }

  handleUnbound() {
    this.setState({ on: !this.state.on }); // TypeError when clicked: this is undefined
  }

  handleBound() {
    this.setState({ on: !this.state.on });
  }

  // Fix 2: a class field holding an arrow function. Arrows take 'this' from where
  // they are defined, which here is the instance.
  handleArrow = () => {
    this.setState({ on: !this.state.on });
  };

  render() {
    return (
      <>
        <button onClick={this.handleUnbound}>Broken</button>
        <button onClick={this.handleBound}>Bound in constructor</button>
        <button onClick={this.handleArrow}>Class field arrow</button>
        {/* Fix 3: an inline arrow. Works, but creates a new function on every render. */}
        <button onClick={() => this.handleUnbound()}>Inline arrow</button>
      </>
    );
  }
}
  • This is plain JavaScript behaviour, not a React rule: const fn = obj.method; fn() loses this anywhere.
  • The class-field arrow became the standard fix, because it needs no constructor.
  • Function components have no this, so the whole category of bug is gone. It is one of the reasons hooks were introduced.
FOLLOW-UPS
What is the difference between super() and super(props)?
Both run the parent constructor. Passing props makes this.props available inside your own constructor. Without it, this.props is undefined until the constructor returns, although React assigns it afterwards.
Is a constructor required?
No. With class fields for state and arrow-function handlers, most class components never needed one.
react.dev
04Which old React APIs are deprecated or removed, and what replaces them?Mid
SHORT ANSWER

React 18 replaced the root API, and React 19 removed most of what had been deprecated for years: ReactDOM.render, string refs, findDOMNode, legacy context, and propTypes and defaultProps on function components. Each has a direct modern equivalent.

OldStatusUse instead
ReactDOM.render(<App />, el)Removed in 19createRoot(el).render(<App />)
ReactDOM.hydrateRemoved in 19hydrateRoot(el, <App />)
unmountComponentAtNode(el)Removed in 19root.unmount()
ReactDOM.findDOMNode(this)Removed in 19A ref on the element
String refs (ref="input")Removed in 19useRef or a callback ref
propTypes on function componentsIgnored in 19TypeScript
defaultProps on function componentsRemoved in 19Default parameter values
Legacy context (contextTypes, getChildContext)Removed in 19createContext
forwardRefUnnecessary in 19, to be deprecatedref as a normal prop
<Context.Provider>Still works<Context> directly
componentWillMount and friendsUNSAFE_ onlycomponentDidMount or effects
Mixins, createReactClassLong goneHooks
Event pooling, event.persist()Removed in 17Nothing needed
react-test-renderer, Enzyme shallow renderingDeprecatedReact Testing Library
Create React AppDeprecated in 2025A framework, or Vite

Interview line: For an upgrade I go to the last 18 release first and fix every warning under Strict Mode, because 19 removes exactly what 18 warns about. Then I run the codemods and update the type packages.

FOLLOW-UPS
Why were string refs removed?
They were tied to the component that happened to be rendering, which broke with render props and composition, could not be statically analysed, and forced React to track the current owner. Object and callback refs have none of those problems.
What were mixins, and why did React abandon them?
A way to merge shared methods into createReactClass components. They created implicit dependencies and name clashes, and did not work with ES classes. Higher-order components replaced them, and hooks replaced those.
react.dev
05How would you convert a class component to a function component with hooks?Mid
SHORT ANSWER

Mechanically at first: state becomes useState or useReducer, instance fields become refs, lifecycle methods become effects grouped by what they synchronise, and this disappears. Then go back over it, because a faithful translation of lifecycle code usually reveals effects you do not need.

  1. Make sure it is covered by tests that exercise behaviour, so you can tell if you broke it.
  2. State. One useState per independent value, or useReducer if they change together. Remember that the setter replaces where setState merged.
  3. Instance fields (this.timer, this.controller) become useRef.
  4. Lifecycle. Do not translate method by method. Group by concern: the subscribe in componentDidMount and the unsubscribe in componentWillUnmount are one effect.
  5. componentDidUpdate comparisons (prevProps.id !== this.props.id) become the effect's dependency array.
  6. Derived state. Anything computed in getDerivedStateFromProps or synced in componentDidUpdate is usually just a calculation during render.
  7. Handlers become plain functions. No binding.
  8. Leave error boundaries as classes. There is no hook for them.
The behavioural difference that is not obvious
import { Component } from "react";

class ProfileClass extends Component<{ user: string }> {
  handleClick = () => {
    // 'this' is mutable: three seconds later, this.props is whatever the latest props are.
    setTimeout(() => alert("Followed " + this.props.user), 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

function ProfileFunction({ user }: { user: string }) {
  function handleClick() {
    // 'user' is a constant of this render: the alert shows who was on screen at the click.
    setTimeout(() => alert("Followed " + user), 3000);
  }

  return <button onClick={handleClick}>Follow</button>;
}

Click Follow on one profile and switch to another within three seconds. The class alerts the new profile's name, which is a bug; the function alerts the one you clicked. Function components capture the values of the render they belong to. That is usually what you want, and it is also the source of stale closures when you do not.

The trap: A conversion is not required. Class components are fully supported, and a working one can stay as it is. Convert when you are changing the component anyway, or when you need a hook-only feature inside it.

react.dev
06What is the difference between createElement and cloneElement? What is the Children API?Mid
SHORT ANSWER

createElement(type, props, ...children) makes a new element; it is what JSX compiles to. cloneElement(element, props) copies an existing element with extra props merged in. Children is a set of helpers for inspecting and transforming the children prop. The last two are legacy patterns for letting a parent inject props into its children.

import { Children, cloneElement, createElement, isValidElement, type ReactNode } from "react";

const button = createElement("button", { className: "btn" }, "Save");
// identical to <button className="btn">Save</button>

const primary = cloneElement(button, { className: "btn btn-primary" });
// a copy: new props merged over the old ones; key and ref are kept

function RadioGroup({ name, children }: { name: string; children: ReactNode }) {
  return (
    <div role="radiogroup">
      {Children.map(children, (child) =>
        isValidElement<{ name?: string }>(child) ? cloneElement(child, { name }) : child,
      )}
    </div>
  );
}
createElementcloneElement
InputA type (tag or component), props, childrenAn existing element, plus overrides
Used byThe JSX compiler; dynamic element typesParents that add props to children they receive
TodayRarely written by handDiscouraged
  • cloneElement makes data flow invisible: the child gets a prop that appears nowhere in the JSX where the child is written.
  • Children.map only sees the elements passed directly. Wrap a child in another component or a fragment and the injection silently stops working.
  • The modern alternatives are context (the compound component pattern), a render prop, or simply passing the prop explicitly.
FOLLOW-UPS
When is createElement still useful?
When the element type is only known at runtime and you are not in a JSX file, for example in a library. In JSX the same is done with a capitalised variable: const Tag = level === 1 ? "h1" : "h2"; return <Tag />.
react.dev

Want reps on this? Drill Hooks & Lifecycles →

JavaScript

JavaScript under the hood

React and TypeScript interviews always dip into the language underneath. Closures explain hooks, the event loop explains effects and batching, and reference equality explains re-renders. Expect 'what does this log?'.

10
01What is a closure? What does this code log?Junior
SHORT ANSWER

A closure is a function together with the variables that were in scope where it was created. The function keeps access to those variables, live, not as copies, for as long as it exists, even after the outer function has returned.

function createCounter() {
  let count = 0;
  const label = "count is " + count; // evaluated once, right here

  return {
    increment() {
      count += 1;
    },
    read() {
      return count;
    },
    describe() {
      return label;
    },
  };
}

const counter = createCounter();
counter.increment();
counter.increment();

console.log(counter.read());
console.log(counter.describe());
stdout
2
count is 0

read closes over the variable count, so it sees the current value. describe closes over label, which is a string computed once when count was 0. A closure captures variables, not expressions: nothing re-evaluates that concatenation.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var", i), 0);
}

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let", j), 0);
}
stdout
var 3
var 3
var 3
let 0
let 1
let 2

var is function-scoped, so all three callbacks share one i, which is 3 by the time they run. let creates a new binding for every iteration, so each callback has its own j.

  • Used for: private state (the counter above), function factories, callbacks and event handlers, memoisation, partial application.
  • In React: every render creates new handler and effect functions that close over that render's props and state. That is why a handler sees the values from the render it was created in, and why a missing dependency produces a stale closure.
FOLLOW-UPS
Can closures cause memory leaks?
They keep whatever they reference alive. A long-lived callback (a listener, an interval, a cache entry) that closes over a large object or a DOM node prevents it from being collected until the callback itself is released.
How would you fix the var loop without let?
Create a new scope per iteration: an immediately invoked function that takes i as a parameter, or pass i as the third argument to setTimeout.
developer.mozilla.org
02How is this determined? What does this code log?Mid
SHORT ANSWER

For a regular function, this is decided by how the function is called, not where it is defined. For an arrow function, this is taken from the enclosing scope at the point of definition and can never be changed.

const user = {
  name: "Ada",
  greet() {
    console.log("method:", this.name);

    const inner = function () {
      console.log("function:", this === undefined ? "no this" : this.name);
    };
    const arrow = () => {
      console.log("arrow:", this.name);
    };

    inner();
    arrow();
  },
};

user.greet();

const { greet } = user;
try {
  greet();
} catch (error) {
  console.log("detached:", error.constructor.name);
}
stdout
method: Ada
function: no this
arrow: Ada
detached: TypeError
How it is calledthis is
obj.method()obj
fn() (a plain call)undefined in strict mode and modules; the global object in sloppy scripts
fn.call(x), fn.apply(x), fn.bind(x)()x
new Fn()The newly created object
An arrow functionWhatever this is in the surrounding scope
A DOM listener added with addEventListenerThe element (for regular functions)
  • inner() is a plain call, so it has no this, even though it is written inside a method.
  • arrow has no this of its own; it uses greet's, which is user.
  • Destructuring greet detaches it from user. Called bare, this is undefined, and reading .name throws.
FOLLOW-UPS
How does this relate to React?
It is the reason class component handlers had to be bound: onClick={this.handleClick} passes the method detached from its instance. Function components do not use this at all.
Can you re-bind an arrow function with call or bind?
No. The first argument is ignored. An arrow's this is fixed lexically.
What does bind return?
A new function permanently bound to the given this (and optionally to leading arguments). Binding it again does not change it.
developer.mozilla.org
03Explain the event loop. In what order does this code log?Mid
SHORT ANSWER

JavaScript runs one thing at a time on a call stack. When the stack is empty, the event loop first runs every queued microtask (promise callbacks, queueMicrotask, the continuation after an await), and only then takes one macrotask (a timer, an I/O callback, a UI event). Then it repeats.

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve()
  .then(() => console.log("C"))
  .then(() => console.log("D"));

queueMicrotask(() => console.log("E"));

(async () => {
  console.log("F");
  await null;
  console.log("G");
})();

console.log("H");
stdout
A
F
H
C
E
G
D
B
  • Synchronous first: A, then F (an async function runs synchronously up to its first await), then H.
  • Then all microtasks, in the order they were queued: C, E, G. Running C queues D, which goes to the back of the microtask queue and still runs before any timer.
  • Then one macrotask: B, even though its delay was 0.
setTimeout(() => {
  console.log("timeout 1");
  Promise.resolve().then(() => console.log("microtask from timeout 1"));
}, 0);

setTimeout(() => console.log("timeout 2"), 0);
stdout
timeout 1
microtask from timeout 1
timeout 2

The microtask queue is drained completely after every macrotask. A microtask scheduled inside the first timer therefore runs before the second timer, and a microtask that keeps scheduling more microtasks can starve timers and rendering indefinitely.

MicrotasksMacrotasks
.then / .catch / .finally callbackssetTimeout, setInterval
Code after awaitDOM events, I/O, MessageChannel
queueMicrotask, MutationObserverThe browser renders between macrotasks
FOLLOW-UPS
Is the function passed to new Promise(...) asynchronous?
No. The executor runs synchronously, immediately. Only the .then callbacks are deferred.
Why does a long loop freeze the page?
Rendering and input handling happen between tasks. While one task is running, nothing else can, including repainting. Break the work up, or move it to a Web Worker.
Where does this show up in React?
Batching: every state update in one task is collected into a single render. And timing: effects run after the browser has painted, layout effects before.
developer.mozilla.org
04How do promises work? What is the difference between Promise.all, allSettled, race and any?Mid
SHORT ANSWER

A promise represents a value that will exist later. It is pending, then settles exactly once, as fulfilled or rejected. async/await is syntax over the same thing. The four combinators differ in what they wait for and how they treat failure.

CombinatorFulfils whenRejects whenUse for
Promise.allAll fulfil; gives an array in input orderAny one rejects (immediately)Independent requests that are all required
Promise.allSettledAll have settled; never rejectsNeverReporting each outcome; partial success is fine
Promise.raceThe first one settles, if it fulfilledThe first one settles, if it rejectedTimeouts
Promise.anyThe first one fulfilsAll reject (AggregateError)Fallbacks: the first mirror that answers
const after = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const failAfter = (ms) => new Promise((_, reject) => setTimeout(() => reject(new Error("boom")), ms));

console.log(await Promise.all([after(20, "a"), after(10, "b")]));

const settled = await Promise.allSettled([after(10, "a"), failAfter(5)]);
console.log(settled.map((result) => result.status));

console.log(await Promise.race([after(30, "slow"), after(5, "fast")]));
console.log(await Promise.any([failAfter(5), after(20, "ok")]));

try {
  await Promise.all([after(20, "a"), failAfter(5)]);
} catch (error) {
  console.log(error.message);
}
stdout
[ 'a', 'b' ]
[ 'fulfilled', 'rejected' ]
fast
ok
boom
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function sequential(ids) {
  const results = [];
  for (const id of ids) {
    await delay(100); // each waits for the previous one
    results.push(id);
  }
  return results;
}

async function parallel(ids) {
  return Promise.all(
    ids.map(async (id) => {
      await delay(100); // all three are in flight together
      return id;
    }),
  );
}

let start = Date.now();
await sequential([1, 2, 3]);
const sequentialMs = Date.now() - start;

start = Date.now();
await parallel([1, 2, 3]);
const parallelMs = Date.now() - start;

console.log(sequentialMs >= 300, parallelMs < 300);
stdout
true true

The trap: await inside a for loop runs the requests one after another. That is correct when each depends on the previous, and a threefold slowdown when they are independent.

FOLLOW-UPS
Does Promise.all cancel the others when one rejects?
No. It rejects early, but the other operations keep running and their results are discarded. Promises cannot be cancelled; you cancel the underlying work with an AbortController.
What happens to an error thrown in an async function?
It becomes a rejection of the promise the function returned. If nothing awaits or catches it, you get an unhandled rejection.
How do you add a timeout to a request?
fetch(url, { signal: AbortSignal.timeout(5000) }), which also cancels the request. Racing against a timer only stops you waiting; the request carries on.
developer.mozilla.org
05What is the difference between a shallow copy and a deep copy? What does this log?Junior
SHORT ANSWER

Primitives are copied by value. Objects, arrays and functions are held by reference, so copying the variable copies the pointer. A shallow copy duplicates the top level and shares everything nested; a deep copy duplicates the whole structure.

const original = { name: "Ada", address: { city: "London" }, tags: ["a"] };

const shallow = { ...original };
const deep = structuredClone(original);

shallow.name = "Grace"; // a primitive on the copy: the original is unaffected
shallow.address.city = "Paris"; // a nested object: shared with the original
deep.tags.push("b"); // fully independent

console.log(original.name);
console.log(original.address.city);
console.log(original.tags.length);
console.log(original.address === shallow.address, original.address === deep.address);
stdout
Ada
Paris
1
true false
TechniqueDepthNotes
{ ...obj }, [...arr], Object.assign, sliceShallowFast; what React state updates use
structuredClone(obj)DeepHandles dates, maps, sets and cycles; cannot clone functions or DOM nodes
JSON.parse(JSON.stringify(obj))DeepLoses undefined, functions, dates (become strings), maps and sets

Interview line: This is the entire reason React state must be updated immutably. React, memo and dependency arrays compare with Object.is, which for objects compares references. Mutate in place and the reference is unchanged, so as far as React can tell, nothing happened.

const a = { id: 1 };
const b = { id: 1 };
const c = a;

console.log(a === b, a === c, Object.is(a, b));
console.log([] === [], "x" === "x", NaN === NaN, Object.is(NaN, NaN));
stdout
false true false
false true false true
FOLLOW-UPS
When updating nested React state, do you need a deep copy?
No, and you should not make one. Copy only the objects along the path you are changing and reuse everything else. That is cheaper, and it keeps the untouched branches reference-equal so memoised components that depend on them do not re-render.
Does const make an object immutable?
No. It prevents reassigning the variable. The object can still be changed. Object.freeze prevents changes to its own properties, shallowly.
developer.mozilla.org
06What is the difference between == and ===? What does this log?Junior
SHORT ANSWER

=== compares without converting types. == converts the operands to a common type first, following rules few people can recite. Use === everywhere, with the one accepted exception of value == null, which is true for both null and undefined.

console.log(1 + "2");
console.log("3" * "4");
console.log([1, 2] + [3]);
console.log([] == false);
console.log(null == undefined, null === undefined);
console.log(NaN === NaN, Object.is(NaN, NaN));
console.log(0.1 + 0.2 === 0.3);
console.log(typeof null, typeof [], typeof (() => {}));
stdout
12
12
1,23
true
true false
false true
false
object object function
  • + concatenates if either operand is a string; every other arithmetic operator converts to numbers. Hence "12" and then 12.
  • Arrays convert to strings by joining with commas: "1,2" + "3".
  • [] == false: the array becomes "", which becomes 0, and false becomes 0.
  • NaN is not equal to anything, including itself. Number.isNaN and Object.is are how you detect it.
  • 0.1 + 0.2 is 0.30000000000000004. Binary floating point cannot represent those decimals exactly; compare with a tolerance, or work in integers (cents).
  • typeof null being "object" is a bug from JavaScript's first release that can never be fixed. Use Array.isArray for arrays.
Falsy values (the complete list)Truthy surprises
false, 0, -0, 0n, "", null, undefined, NaN"0", "false", [], {}, new Boolean(false)
FOLLOW-UPS
Where does truthiness bite in React?
{count && <List />} renders 0 when count is zero, because 0 && anything evaluates to 0, and React renders numbers. Write count > 0 &&.
|| or ?? for a default value?
?? falls back only on null and undefined. || falls back on every falsy value, so a legitimate 0 or empty string gets replaced.
developer.mozilla.org
07What is hoisting? What is the difference between var, let and const?Junior
SHORT ANSWER

Declarations are processed before any code runs, so a name exists throughout its scope. var and function declarations are usable from the top (a var holds undefined until assigned). let and const exist but cannot be touched until their declaration line; that gap is the temporal dead zone.

console.log(typeof hoistedFunction, typeof hoistedVar);

try {
  console.log(notYet);
} catch (error) {
  console.log(error.name);
}

var hoistedVar = 1;
let notYet = 2;
function hoistedFunction() {}
stdout
function undefined
ReferenceError
varletconst
ScopeFunctionBlockBlock
Before its declarationundefinedReferenceErrorReferenceError
ReassignYesYesNo
Redeclare in the same scopeYesNoNo
Creates a property on the global objectYes (in scripts)NoNo
  • Default to const; use let when the variable is reassigned; do not use var.
  • const fixes the binding, not the value: const list = []; list.push(1) is fine.
  • Function declarations are hoisted with their body. Function expressions and arrow functions assigned to a variable follow that variable's rules.
  • Classes are hoisted like let: using one before its declaration throws.
developer.mozilla.org
08How does prototypal inheritance work? What does this log?Mid
SHORT ANSWER

Every object has a hidden link to another object, its prototype. Reading a property that the object does not have follows that link, and the next, until the property is found or the chain ends. Writing never follows the chain: assignment always creates or updates a property on the object itself.

const defaults = { theme: "light", tags: ["base"] };
const settings = Object.create(defaults);

settings.theme = "dark"; // a write: creates an own property on settings
settings.tags.push("custom"); // a read, then a mutation of the array that defaults owns

console.log(defaults.theme);
console.log(defaults.tags);
console.log(Object.keys(settings));
console.log(Object.hasOwn(settings, "tags"));
stdout
light
[ 'base', 'custom' ]
[ 'theme' ]
false

Assigning settings.theme shadows the inherited property; defaults is untouched. But settings.tags.push(...) does not assign to settings at all. It looks tags up, finds the array on defaults, and mutates that shared array.

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    return this.name + " makes a sound";
  }
}

class Dog extends Animal {
  speak() {
    return super.speak() + ": woof";
  }
}

const rex = new Dog("Rex");

console.log(rex.speak());
console.log(Object.getPrototypeOf(rex) === Dog.prototype);
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);
console.log(Object.hasOwn(rex, "name"), Object.hasOwn(rex, "speak"));
stdout
Rex makes a sound: woof
true
true
true false
  • class is syntax over this mechanism. Methods live once on Dog.prototype; instances hold only their own data.
  • extends links Dog.prototype to Animal.prototype, so a lookup falls through from one to the other.
  • Object.create(proto) makes an object with the given prototype directly, with no constructor involved.
FOLLOW-UPS
What is the difference between __proto__ and prototype?
prototype is a property of constructor functions and classes: the object that will become the prototype of instances created with new. __proto__ (properly, Object.getPrototypeOf(obj)) is the actual link an object has to its prototype.
Why is mutable state on a prototype dangerous?
It is shared by every object that inherits from it. One instance pushing to an inherited array changes it for all of them, as the first example shows.
developer.mozilla.org
09Which array methods mutate, and which return a new array? What does this log?Junior
SHORT ANSWER

map, filter, slice, concat, flat and the newer toSorted, toReversed, toSpliced and with return a new array. push, pop, shift, unshift, splice, sort and reverse change the array in place. Knowing which is which matters in React, where state must not be mutated.

const numbers = [3, 1, 10, 2];
const sorted = numbers.sort();

console.log(sorted);
console.log(numbers === sorted);
console.log([3, 1, 10, 2].toSorted((a, b) => a - b));
console.log(["1", "2", "3"].map(parseInt));
console.log([1, 2, 3, 4].reduce((sum, n) => sum + n, 0));
stdout
[ 1, 10, 2, 3 ]
true
[ 1, 2, 3, 10 ]
[ 1, NaN, NaN ]
10
  • sort() with no comparator converts elements to strings, so 10 sorts before 2. Always pass (a, b) => a - b for numbers.
  • sort() sorts in place and returns the same array. Sorting React state directly mutates it; use toSorted or copy first.
  • map calls its callback with (value, index, array). parseInt takes (string, radix), so the index is used as the radix: parseInt("2", 1) is NaN. Use map(Number) or an explicit arrow.
GoalMethodReturns
Transform each itemmapA new array of the same length
Keep some itemsfilterA new, possibly shorter array
Combine into one valuereduceAnything
Find one itemfind, findLastThe item, or undefined
Test itemssome, every, includesA boolean
Side effects onlyforEachundefined
Flatten after mappingflatMapA new array
FOLLOW-UPS
Why can you not break out of forEach?
It is a function call per item, not a loop. Use for...of when you need break, continue or await, or some/find when you want to stop at the first match.
How do you remove duplicates from an array?
[...new Set(items)] for primitives. For objects, by key: build a Map keyed by ID and take its values.
developer.mozilla.org
10What causes memory leaks in a React application?Senior
SHORT ANSWER

Something long-lived keeps a reference to something that should have been released. In React it is nearly always a subscription, listener or timer created in an effect and never cleaned up, so the callback, and everything its closure captured, outlives the component.

Every setup needs a matching cleanup
function LivePrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    const socket = new WebSocket("wss://example.com/prices/" + symbol);
    socket.onmessage = (event) => setPrice(Number(event.data));

    const onResize = () => console.log(window.innerWidth);
    window.addEventListener("resize", onResize);

    const id = setInterval(() => socket.send("ping"), 30_000);

    // Without this, every symbol change and every unmount leaves behind an open
    // socket, a window listener and an interval, each holding this closure alive.
    return () => {
      socket.close();
      window.removeEventListener("resize", onResize);
      clearInterval(id);
    };
  }, [symbol]);

  return <output>{price ?? "..."}</output>;
}
SourceFix
Listeners on window, document or a long-lived elementRemove them in the effect's cleanup
setInterval, setTimeout, requestAnimationFrameClear or cancel in the cleanup
WebSockets, observers, store subscriptionsClose, disconnect or unsubscribe in the cleanup
Requests that resolve after unmountAbort them; the callback otherwise holds the component's closure
Module-level caches and maps that only growBound the size, expire entries, or use WeakMap keyed by object
References to removed DOM nodesDrop the reference; avoid storing nodes in global structures
Third-party widgets (maps, charts, editors)Call their destroy method in the cleanup
  • Find them with the browser's Memory panel: take a heap snapshot, repeat the suspect interaction several times (open and close the dialog, navigate back and forth), snapshot again, and compare. Look for detached DOM nodes and counts that grow with each repetition.
  • Strict Mode helps: its mount, unmount, remount cycle makes a missing cleanup visible as duplicated behaviour during development.
FOLLOW-UPS
What is the difference between a WeakMap and a Map here?
A Map keeps its keys alive. A WeakMap does not: when nothing else references the key object, the entry can be collected. That makes it the right structure for attaching data to objects you do not own.
react.dev

Want reps on this? Drill Async/Await & Promises →

Live coding

Live coding exercises

The practical round. These are the components and hooks that get asked for again and again. Write each one yourself before opening the solution, out loud, as you would in the room.

16
01How do you approach a live coding exercise?Junior
SHORT ANSWER

Slow down at the start. Clarify what is being asked, say what state you need and where it lives, build the simplest working version end to end, and only then handle loading, errors and edge cases. Narrate throughout: the interviewer is assessing how you think, and silence gives them nothing.

  1. Clarify (two minutes). What are the inputs and outputs? Which states exist: loading, empty, error? Does it need to persist, be accessible, be styled? Restate the task in one sentence.
  2. Design out loud. Name the components and the state: what is stored, what is derived, who owns it. Mention the alternative you rejected.
  3. Happy path first. Get something on screen that works for the main case before polishing any part of it.
  4. Then the unhappy paths. Loading, error, empty, invalid input, rapid repeated actions.
  5. Test it by hand and say what you are checking. Fix what you find without apologising.
  6. Close. Say what you would do with more time: tests, accessibility, extracting a hook, performance for large inputs.
A quick self-review before you say 'done'
StateNothing stored that could be computed; no mutation; updater functions where the next value depends on the last
ListsStable keys from the data, not the index
EffectsEvery setup has a cleanup; dependencies are complete; requests can be cancelled
FormsInputs have labels; buttons have a type; submit is handled on the form, not on the button
AsyncLoading, error and empty are each rendered
TypesNo any; props typed; unions for anything with a fixed set of values

Interview line: If I am unsure about something, I say so and say how I would find out. 'I do not remember whether this fires before paint; I would check the docs, and for now I will assume it does not' is a strong answer. A confident guess that turns out wrong is not.

FOLLOW-UPS
What if you get stuck?
Say where you are stuck and what you have ruled out, then reduce the problem: hard-code the data, drop a requirement temporarily, or write the pure function first and the component second. Asking a precise question is fine; sitting in silence is not.
react.dev
02Build a counter with increment, decrement and reset, bounded by a minimum and maximum.Junior
SHORT ANSWER

One number in state. The buttons use the updater form, clamp the result, and disable themselves at the bounds. The disabled flags are derived during render, not stored.

interface CounterProps {
  initial?: number;
  min?: number;
  max?: number;
  step?: number;
}

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

export function Counter({ initial = 0, min = -Infinity, max = Infinity, step = 1 }: CounterProps) {
  const [count, setCount] = useState(() => clamp(initial, min, max));

  const change = (delta: number) => setCount((current) => clamp(current + delta, min, max));

  return (
    <div role="group" aria-label="Counter">
      <button type="button" onClick={() => change(-step)} disabled={count <= min} aria-label="Decrease">
        −
      </button>
      <output aria-live="polite">{count}</output>
      <button type="button" onClick={() => change(step)} disabled={count >= max} aria-label="Increase">
        +
      </button>
      <button type="button" onClick={() => setCount(clamp(initial, min, max))}>
        Reset
      </button>
    </div>
  );
}
  • Updater function. setCount((current) => ...) stays correct when the button is clicked faster than React re-renders.
  • initial, not value. The name signals that the prop seeds the state once and is not kept in sync.
  • Derived, not stored. count <= min is computed each render. A separate isAtMin state would be a second source of truth.
  • Icon-only buttons need a name. aria-label gives a screen reader something to announce.
FOLLOW-UPS
How would you make it controllable by a parent?
Accept value and onChange and hold no state of its own, like a native input. To support both, use the controlled value when value is provided and internal state otherwise.
How would you add 'hold to repeat'?
Start an interval on pointer down and clear it on pointer up, pointer leave and unmount. The interval callback must use the updater form, or it will repeat with a stale count.
react.dev
03Build a todo list: add, toggle, delete, and filter by status.Junior
SHORT ANSWER

Two pieces of state: the list of todos and the current filter. The visible list and the remaining count are derived. Every update returns a new array, and each todo carries an ID created once, when it is added.

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

type Filter = "all" | "active" | "done";
const FILTERS: Filter[] = ["all", "active", "done"];

export function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>("all");
  const [draft, setDraft] = useState("");

  const visible = todos.filter((todo) => filter === "all" || todo.done === (filter === "done"));
  const remaining = todos.filter((todo) => !todo.done).length;

  function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    const title = draft.trim();
    if (title === "") return;
    setTodos((current) => [...current, { id: crypto.randomUUID(), title, done: false }]);
    setDraft("");
  }

  const toggle = (id: string) =>
    setTodos((current) => current.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)));

  const remove = (id: string) => setTodos((current) => current.filter((todo) => todo.id !== id));

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <label htmlFor="new-todo">New todo</label>
        <input id="new-todo" value={draft} onChange={(event) => setDraft(event.target.value)} />
        <button type="submit">Add</button>
      </form>

      <div role="group" aria-label="Filter">
        {FILTERS.map((option) => (
          <button key={option} type="button" aria-pressed={filter === option} onClick={() => setFilter(option)}>
            {option}
          </button>
        ))}
      </div>

      {visible.length === 0 ? (
        <p>Nothing here.</p>
      ) : (
        <ul>
          {visible.map((todo) => (
            <li key={todo.id}>
              <label>
                <input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
                {todo.title}
              </label>
              <button type="button" onClick={() => remove(todo.id)} aria-label={"Delete " + todo.title}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}

      <p>{remaining} left</p>
    </section>
  );
}
  • Submit on the form. Handling onSubmit makes Enter work for free; a click handler on the button does not.
  • Keys are IDs. With index keys, deleting the first item would shift every checkbox onto the wrong todo.
  • The filtered list is not state. It is recomputed from todos and filter, so it can never be out of date.
  • An empty state. A blank area reads as broken; say that there is nothing to show.
FOLLOW-UPS
How would you persist the list?
Initialise state lazily from localStorage and write back in an effect that depends on todos, guarding both with try/catch. Under server rendering, read after mount to avoid a hydration mismatch.
It now has 10,000 todos and typing in the input lags. Why, and what do you do?
Every keystroke re-renders the whole list. Move the draft input into its own component so its state is local, memoise the row component, and virtualise the list.
When would you switch to useReducer?
When the operations multiply (edit, reorder, bulk complete, undo). A reducer keeps every transition in one testable function and gives children a single stable dispatch.
react.dev
04Write a useFetch hook.Mid
SHORT ANSWER

State is a discriminated union of loading, success and error. An effect keyed on the URL makes the request with an AbortController, and its cleanup aborts, which handles both unmounting and a URL that changes while a request is in flight.

type FetchState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

export function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });

    async function load() {
      try {
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error("Request failed with " + res.status);
        const data = (await res.json()) as T;
        setState({ status: "success", data });
      } catch (error) {
        if (controller.signal.aborted) return; // superseded or unmounted
        setState({ status: "error", error: error instanceof Error ? error : new Error(String(error)) });
      }
    }

    void load();
    return () => controller.abort();
  }, [url]);

  return state;
}

interface Repo {
  id: number;
  name: string;
}

function RepoList({ user }: { user: string }) {
  const state = useFetch<Repo[]>("https://api.github.com/users/" + user + "/repos");

  if (state.status === "loading") return <p>Loading</p>;
  if (state.status === "error") return <p role="alert">{state.error.message}</p>;
  if (state.data.length === 0) return <p>No repositories.</p>;

  return (
    <ul>
      {state.data.map((repo) => (
        <li key={repo.id}>{repo.name}</li>
      ))}
    </ul>
  );
}
  • A union, not three booleans. isLoading, error and data as separate values allow combinations that make no sense. The union lets the compiler narrow state.data after the checks.
  • The effect is not async. It declares an async function inside and calls it, so it can still return a cleanup.
  • Check res.ok. fetch resolves for a 404 or a 500.
  • Abort is not an error. An aborted request rejects; that rejection must not be shown to the user.

The trap: useFetch<Repo[]> looks type-safe and is not: the generic is an unchecked assertion about what the server returns. Say so, and mention validating the response for anything that matters.

FOLLOW-UPS
What is missing compared with a real data library?
Caching, de-duplication of identical requests, background revalidation, retries, pagination, mutations with invalidation, and server rendering. That list is the argument for TanStack Query or SWR.
How would you add a refetch function?
Keep a counter in state, include it in the effect's dependencies, and return a function that increments it.
The component shows the previous user's repositories for a moment when user changes. Why?
It does not here, because the effect sets loading before the request. A version that only sets state on success would keep the old data until the new response arrives, which is sometimes exactly what you want (stale-while-revalidate) and sometimes a bug.
react.dev
06Write a useLocalStorage hook.Mid
SHORT ANSWER

It behaves like useState but reads its initial value from localStorage and writes changes back. The details that matter are a lazy initialiser, guarding every storage access with try/catch, not touching window on the server, and listening for changes made in other tabs.

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === "undefined") return initialValue; // server render
    try {
      const stored = window.localStorage.getItem(key);
      return stored === null ? initialValue : (JSON.parse(stored) as T);
    } catch {
      return initialValue; // storage blocked, or the stored value is not valid JSON
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Quota exceeded or storage disabled: the in-memory value still works.
    }
  }, [key, value]);

  useEffect(() => {
    function onStorage(event: StorageEvent) {
      if (event.key !== key || event.newValue === null) return;
      try {
        setValue(JSON.parse(event.newValue) as T);
      } catch {
        // Ignore a value another tab wrote in a format we cannot read.
      }
    }
    window.addEventListener("storage", onStorage); // fires for changes made in OTHER tabs
    return () => window.removeEventListener("storage", onStorage);
  }, [key]);

  return [value, setValue] as const;
}

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "dark");
  return <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>Theme: {theme}</button>;
}
  • Lazy initialiser. Reading and parsing storage once, not on every render.
  • localStorage can throw. In private browsing, when blocked by policy, or when the quota is full.
  • It only stores strings. JSON.stringify loses dates, maps, sets and undefined.
  • as const makes the return a tuple, so callers get the same shape as useState.

The trap: With server rendering this version causes a hydration mismatch: the server renders initialValue, and the client's first render reads the stored value. Either read storage in an effect after mount (accepting a brief flash), or use useSyncExternalStore with a server snapshot.

FOLLOW-UPS
How do you stop a theme from flashing on load?
No hook can run early enough. Put a tiny blocking script in the document head that reads the preference and sets a class or attribute on <html> before the first paint, or store the preference in a cookie so the server can render the right theme.
Why is this unsuitable for auth tokens?
Anything in localStorage is readable by every script on the page, so one XSS bug exposes it. Session credentials belong in HttpOnly cookies.
react.dev
07Build a countdown timer. Write the useInterval hook it needs.Mid
SHORT ANSWER

The difficulty is that an interval outlives renders, so its callback goes stale. useInterval keeps the latest callback in a ref and reads it from the timer, so the interval is created once and restarted only when the delay changes. Passing null as the delay pauses it.

function useInterval(callback: () => void, delayMs: number | null) {
  const latest = useRef(callback);

  useEffect(() => {
    latest.current = callback; // after every render, point at the newest callback
  });

  useEffect(() => {
    if (delayMs === null) return; // paused
    const id = setInterval(() => latest.current(), delayMs);
    return () => clearInterval(id);
  }, [delayMs]);
}

export function Countdown({ seconds }: { seconds: number }) {
  const [remaining, setRemaining] = useState(seconds);
  const [running, setRunning] = useState(false);

  const ticking = running && remaining > 0;
  useInterval(() => setRemaining((current) => Math.max(0, current - 1)), ticking ? 1000 : null);

  const minutes = String(Math.floor(remaining / 60)).padStart(2, "0");
  const secs = String(remaining % 60).padStart(2, "0");

  return (
    <div>
      <output role="timer" aria-live="off">
        {minutes}:{secs}
      </output>
      <button type="button" onClick={() => setRunning(!running)} disabled={remaining === 0}>
        {ticking ? "Pause" : "Start"}
      </button>
      <button
        type="button"
        onClick={() => {
          setRunning(false);
          setRemaining(seconds);
        }}
      >
        Reset
      </button>
      {remaining === 0 && <p role="status">Time is up.</p>}
    </div>
  );
}
  • Why not put the callback in the dependency array? It is a new function every render, so the interval would be torn down and recreated each second, drifting further each time.
  • Pausing by delay. null skips the effect, and the cleanup of the previous run has already cleared the timer.
  • ticking is derived from running and remaining. When the count reaches zero the interval stops with no extra state.
  • In React 19.2, useEffectEvent replaces the ref: wrap the callback in it and call that from the interval.

The trap: setInterval is not a clock. Timers are delayed when the tab is in the background or the main thread is busy, so counting ticks drifts. For an accurate timer, store the end timestamp and compute remaining from Date.now() on each tick.

FOLLOW-UPS
What if seconds changes while it is running?
As written, nothing: the prop only seeds the state. To restart for a new duration, render it with key={seconds} so React creates a fresh instance.
react.dev
08Build an accessible accordion.Mid
SHORT ANSWER

State is the set of open panel IDs. Each header is a real <button> inside a heading, with aria-expanded and aria-controls; each panel is a region labelled by its header. A prop switches between one-open-at-a-time and many.

interface AccordionItem {
  id: string;
  title: string;
  content: ReactNode;
}

interface AccordionProps {
  items: AccordionItem[];
  allowMultiple?: boolean;
}

export function Accordion({ items, allowMultiple = false }: AccordionProps) {
  const [openIds, setOpenIds] = useState<ReadonlySet<string>>(() => new Set());
  const baseId = useId();

  function toggle(id: string) {
    setOpenIds((current) => {
      const next = new Set(allowMultiple ? current : []);
      if (current.has(id)) next.delete(id);
      else next.add(id);
      return next; // a new Set: mutating the old one would not re-render
    });
  }

  return (
    <div>
      {items.map((item) => {
        const open = openIds.has(item.id);
        const headerId = baseId + "-header-" + item.id;
        const panelId = baseId + "-panel-" + item.id;

        return (
          <div key={item.id}>
            <h3>
              <button
                type="button"
                id={headerId}
                aria-expanded={open}
                aria-controls={panelId}
                onClick={() => toggle(item.id)}
              >
                {item.title}
              </button>
            </h3>
            <div id={panelId} role="region" aria-labelledby={headerId} hidden={!open}>
              {item.content}
            </div>
          </div>
        );
      })}
    </div>
  );
}
  • A button, not a div. It is focusable and responds to Enter and Space with no extra code.
  • aria-expanded tells assistive technology whether the section is open; aria-controls says which element it governs.
  • hidden removes the panel from both the layout and the accessibility tree while keeping it mounted, so state inside it survives.
  • useId keeps the IDs unique when several accordions are on the page.
FOLLOW-UPS
Should closed panels be unmounted?
It depends. Unmounting ({open && ...}) saves work for heavy content but loses the panel's internal state and scroll position. Keeping it mounted and hidden preserves both. Say which you chose and why.
How would you animate the height?
Animating to height: auto is the classic problem. Options are a CSS grid row transitioning from 0fr to 1fr, the newer interpolate-size, or measuring the content. And honour prefers-reduced-motion.
Is there a native element for this?
<details> and <summary> give you a disclosure with no JavaScript, and the name attribute groups them so only one is open. Use it when you do not need custom behaviour.
w3.org
09Build a star rating component.Mid
SHORT ANSWER

A controlled component: value in, onChange out. Under the stars it is a radio group, which gives keyboard support and screen reader semantics for free. The only local state is which star the pointer is over, for the hover preview.

interface StarRatingProps {
  value: number;
  onChange: (value: number) => void;
  max?: number;
  label?: string;
}

export function StarRating({ value, onChange, max = 5, label = "Rating" }: StarRatingProps) {
  const [hovered, setHovered] = useState<number | null>(null);
  const name = useId();
  const shown = hovered ?? value; // the preview wins while hovering

  return (
    <fieldset onMouseLeave={() => setHovered(null)}>
      <legend>{label}</legend>
      {Array.from({ length: max }, (_, index) => {
        const star = index + 1;
        return (
          <label key={star} onMouseEnter={() => setHovered(star)}>
            <input
              className="visually-hidden"
              type="radio"
              name={name}
              value={star}
              checked={value === star}
              onChange={() => onChange(star)}
            />
            <span aria-hidden="true">{star <= shown ? "★" : "☆"}</span>
            <span className="visually-hidden">
              {star} of {max}
            </span>
          </label>
        );
      })}
    </fieldset>
  );
}

function Review() {
  const [rating, setRating] = useState(0);
  return <StarRating value={rating} onChange={setRating} label="Rate this product" />;
}
  • Controlled by the parent, like a native input, so it drops into any form and can be reset from outside.
  • Radio inputs do the hard part. Arrow keys move between stars, Tab enters and leaves the group, and a screen reader announces '3 of 5, radio button, selected'.
  • The glyphs are decoration (aria-hidden); the accessible name comes from the hidden text.
  • hovered ?? value, not ||: a rating of 0 is a real value.
FOLLOW-UPS
How would you allow clearing the rating?
Radio groups cannot be unselected by clicking, so add an explicit 'Clear' button, or treat a click on the current value as a reset (and say that it is not discoverable by keyboard without the button).
And half stars?
Double the number of radio options (0.5 steps) and draw each star from two halves, or switch the underlying control to a range input, which is the right semantic for a continuous value.
w3.org
10Build a sign-up form with validation, without a library.Mid
SHORT ANSWER

Values and 'touched' flags are state; errors are derived by a pure validate function on every render. Errors are shown for a field once it has been blurred, and for all fields after a submit attempt. The submit handler prevents the default, validates, and tracks a submitting status.

interface Values {
  email: string;
  password: string;
}
type Errors = Partial<Record<keyof Values, string>>;

function validate(values: Values): Errors {
  const errors: Errors = {};
  if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter a valid email address.";
  if (values.password.length < 12) errors.password = "Use at least 12 characters.";
  return errors;
}

export function SignUpForm({ onSubmit }: { onSubmit: (values: Values) => Promise<void> }) {
  const [values, setValues] = useState<Values>({ email: "", password: "" });
  const [touched, setTouched] = useState<Partial<Record<keyof Values, boolean>>>({});
  const [status, setStatus] = useState<"idle" | "submitting" | "failed">("idle");
  const id = useId();

  const errors = validate(values); // derived: always matches the current values
  const visibleError = (field: keyof Values) => (touched[field] ? errors[field] : undefined);

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = event.target;
    setValues((current) => ({ ...current, [name]: value }));
  }

  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
    setTouched((current) => ({ ...current, [event.target.name]: true }));
  }

  async function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    setTouched({ email: true, password: true }); // reveal every error
    if (Object.keys(errors).length > 0) return;

    setStatus("submitting");
    try {
      await onSubmit(values);
      setStatus("idle");
    } catch {
      setStatus("failed");
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {(["email", "password"] as const).map((field) => {
        const error = visibleError(field);
        return (
          <div key={field}>
            <label htmlFor={id + field}>{field === "email" ? "Email" : "Password"}</label>
            <input
              id={id + field}
              name={field}
              type={field}
              value={values[field]}
              onChange={handleChange}
              onBlur={handleBlur}
              aria-invalid={error !== undefined}
              aria-describedby={error ? id + field + "-error" : undefined}
              autoComplete={field === "email" ? "email" : "new-password"}
            />
            {error && (
              <p id={id + field + "-error"} role="alert">
                {error}
              </p>
            )}
          </div>
        );
      })}

      {status === "failed" && <p role="alert">Could not create the account. Try again.</p>}
      <button type="submit" disabled={status === "submitting"}>
        {status === "submitting" ? "Creating account" : "Sign up"}
      </button>
    </form>
  );
}
  • Errors are not state. Storing them would mean remembering to update them on every change. Deriving them cannot go stale.
  • touched stops the form from complaining about fields the user has not reached yet.
  • noValidate turns off the browser's own validation bubbles so yours are the only ones.
  • The button stays enabled when the form is invalid. A disabled submit gives no explanation; clicking it and seeing the errors does.
  • One handleChange for all fields, keyed by the input's name.
FOLLOW-UPS
How would you add 'confirm password' or an async 'email already taken' check?
Cross-field rules belong in validate, which sees all values. An async check runs on blur or debounced on change, stores its result separately, and is merged into the displayed errors; the server must still enforce uniqueness on submit.
At what point do you bring in a library?
When there are many fields, dynamic field arrays, nested objects or performance problems from re-rendering on every keystroke. React Hook Form with a schema removes most of this code.
react.dev
11Build a table with search, sorting and pagination.Mid
SHORT ANSWER

Three small pieces of state: the query, the sort, and the page. Everything else is a pipeline computed from them: filter, then sort, then slice. Sorting copies the array first, and the page is clamped during render instead of being reset in an effect.

interface Column<T> {
  key: keyof T & string;
  header: string;
}

interface DataTableProps<T extends { id: string | number }> {
  rows: T[];
  columns: Column<T>[];
  pageSize?: number;
}

export function DataTable<T extends { id: string | number }>({ rows, columns, pageSize = 10 }: DataTableProps<T>) {
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<{ key: keyof T & string; direction: "asc" | "desc" } | null>(null);
  const [page, setPage] = useState(0);

  const sorted = useMemo(() => {
    const needle = query.trim().toLowerCase();
    const filtered =
      needle === ""
        ? rows
        : rows.filter((row) => columns.some((column) => String(row[column.key]).toLowerCase().includes(needle)));

    if (!sort) return filtered;
    const factor = sort.direction === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => {
      const left = a[sort.key];
      const right = b[sort.key];
      if (typeof left === "number" && typeof right === "number") return (left - right) * factor;
      return String(left).localeCompare(String(right)) * factor;
    });
  }, [rows, columns, query, sort]);

  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const currentPage = Math.min(page, pageCount - 1); // clamp when filtering shrinks the list
  const visible = sorted.slice(currentPage * pageSize, (currentPage + 1) * pageSize);

  function toggleSort(key: keyof T & string) {
    setSort((current) =>
      current?.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" },
    );
  }

  return (
    <div>
      <label>
        Search
        <input
          type="search"
          value={query}
          onChange={(event) => {
            setQuery(event.target.value);
            setPage(0); // a new search starts from the first page
          }}
        />
      </label>

      <table>
        <thead>
          <tr>
            {columns.map((column) => {
              const direction = sort?.key === column.key ? sort.direction : undefined;
              return (
                <th
                  key={column.key}
                  aria-sort={direction === "asc" ? "ascending" : direction === "desc" ? "descending" : "none"}
                >
                  <button type="button" onClick={() => toggleSort(column.key)}>
                    {column.header}
                  </button>
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {visible.map((row) => (
            <tr key={row.id}>
              {columns.map((column) => (
                <td key={column.key}>{String(row[column.key])}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {sorted.length === 0 && <p>No rows match.</p>}

      <nav aria-label="Pagination">
        <button type="button" onClick={() => setPage(currentPage - 1)} disabled={currentPage === 0}>
          Previous
        </button>
        <span>
          Page {currentPage + 1} of {pageCount}
        </span>
        <button type="button" onClick={() => setPage(currentPage + 1)} disabled={currentPage >= pageCount - 1}>
          Next
        </button>
      </nav>
    </div>
  );
}
  • The generic ties columns to rows: a column key that is not a property of the row type does not compile.
  • [...filtered].sort(...): sort mutates, and filtered may be the rows prop itself.
  • Resetting the page happens in the event handler that changes the query, not in an effect watching the query.
  • aria-sort on the header and a real <button> inside it make the sorting usable with a keyboard and a screen reader.
FOLLOW-UPS
The dataset has two million rows. What changes?
Everything moves to the server: the query, sort and page become request parameters (and belong in the URL), the component renders one page of results, and the client's job becomes debouncing input, showing loading state and keeping previous data visible while the next page loads.
Why keep table state in the URL?
So a filtered, sorted view can be bookmarked and shared, survives a refresh, and works with the back button.
react.dev
12Implement infinite scroll.Senior
SHORT ANSWER

Render the items followed by an empty sentinel element, and watch the sentinel with an IntersectionObserver. When it comes into view, load the next page using the cursor from the previous response, and append. Guard against loading twice, and stop when the server says there is no more.

interface Page<T> {
  items: T[];
  nextCursor: string | null; // null means this was the last page
}

interface InfiniteListProps<T extends { id: string }> {
  loadPage: (cursor: string | null) => Promise<Page<T>>;
  renderItem: (item: T) => ReactNode;
}

export function InfiniteList<T extends { id: string }>({ loadPage, renderItem }: InfiniteListProps<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
  const sentinelRef = useRef<HTMLDivElement>(null);

  const loadMore = useCallback(async () => {
    if (status === "loading" || !hasMore) return; // already busy, or finished
    setStatus("loading");
    try {
      const page = await loadPage(cursor);
      setItems((current) => [...current, ...page.items]);
      setCursor(page.nextCursor);
      setHasMore(page.nextCursor !== null);
      setStatus("idle");
    } catch {
      setStatus("error");
    }
  }, [cursor, hasMore, loadPage, status]);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel || status === "error") return; // after an error, wait for an explicit retry

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((entry) => entry.isIntersecting)) void loadMore();
      },
      { rootMargin: "400px" }, // start loading before the user reaches the end
    );
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore, status]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{renderItem(item)}</li>
        ))}
      </ul>

      {status === "loading" && <p aria-busy="true">Loading more</p>}
      {status === "error" && (
        <p role="alert">
          Could not load more. <button onClick={() => void loadMore()}>Retry</button>
        </p>
      )}
      {!hasMore && <p>You have reached the end.</p>}

      <div ref={sentinelRef} aria-hidden="true" />
    </div>
  );
}
DecisionReason
IntersectionObserver, not a scroll listenerNo handler firing on every scroll frame, no layout measurement, works in any scroll container
Cursor pagination, not page numbersWith offsets, an item inserted at the top shifts every page and produces duplicates or gaps
rootMarginThe next page is usually ready before the user sees a spinner
A guard on statusThe observer can fire several times while one request is in flight
A Retry buttonAuto-retrying a failing request forever, on every scroll, is how you take your own API down

The trap: Infinite scroll has real costs: the footer becomes unreachable, there is no way to link to a position, Back loses your place unless you restore it, and the DOM grows without bound. For long sessions combine it with virtualisation, and consider a 'Load more' button, which keeps the user in control.

FOLLOW-UPS
How do you restore the scroll position when the user comes back?
Keep the loaded pages in a cache that outlives the component (a query library does this), and let the browser or router restore the scroll offset once the same content has rendered.
How do screen reader users know more content arrived?
Announce it in a live region ('20 more results loaded') and make sure focus is not lost. A list that silently grows is invisible to them.
developer.mozilla.org
13Code review: find the bugs in this component.Mid
SHORT ANSWER

There are seven: an async effect, a missing dependency, no cancellation, state mutation, an && that renders 0, index keys, and a handler that is called during render instead of passed.

As submitted
function UserList({ teamId }) {
  const [users, setUsers] = useState([]);
  const [selected, setSelected] = useState(null);

  useEffect(async () => {
    const res = await fetch("/api/teams/" + teamId + "/users");
    setUsers(await res.json());
  }, []);

  function remove(id) {
    const index = users.findIndex((user) => user.id === id);
    users.splice(index, 1);
    setUsers(users);
  }

  return (
    <ul>
      {users.length &&
        users.map((user, index) => (
          <li key={index} className={selected === user.id ? "selected" : ""} onClick={setSelected(user.id)}>
            {user.name}
            <button onClick={() => remove(user.id)}>Remove</button>
          </li>
        ))}
    </ul>
  );
}
#BugConsequence
1useEffect(async () => ...)An async function returns a promise; React expects a cleanup function or nothing
2[] but the effect reads teamIdChanging team never refetches: the list shows the old team
3No abort or ignore flagA slow response for a previous team can overwrite the current one
4users.splice then setUsers(users)State is mutated and the reference is unchanged, so React skips the re-render
5users.length && ...Renders the number 0 when the list is empty
6key={index}After a removal, DOM and component state shift to the wrong rows
7onClick={setSelected(user.id)}Calls the setter during render: an infinite re-render loop
Fixed
interface User {
  id: string;
  name: string;
}

function UserList({ teamId }: { teamId: string }) {
  const [users, setUsers] = useState<User[]>([]);
  const [selected, setSelected] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch("/api/teams/" + teamId + "/users", { signal: controller.signal })
      .then((res) => res.json() as Promise<User[]>)
      .then(setUsers)
      .catch(() => {});

    return () => controller.abort();
  }, [teamId]);

  const remove = (id: string) => setUsers((current) => current.filter((user) => user.id !== id));

  if (users.length === 0) return <p>No users.</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} className={selected === user.id ? "selected" : ""}>
          <button type="button" onClick={() => setSelected(user.id)}>
            {user.name}
          </button>
          <button type="button" onClick={() => remove(user.id)}>
            Remove
          </button>
        </li>
      ))}
    </ul>
  );
}

The fixed version also moves the click from the <li> to a <button>, so the row can be selected from the keyboard, and stops a click on Remove from also selecting the row through bubbling.

FOLLOW-UPS
What is still missing from the fixed version?
Loading and error states, a check of res.ok, and validation of the response shape. And selected can point at a user that has since been removed, which should be handled by clearing it or deriving the selected user from the list.
react.dev
14Spot the bug: three small TypeScript functions that compile and are wrong.Junior
SHORT ANSWER

A filter that keeps the odd numbers, a loop that is off by one at both ends, and a factorial that always returns zero. All three type-check, which is the point: the compiler verifies types, not logic.

As written
function evens(numbers: number[]) {
  return numbers.filter((n) => n % 2);
}

function sum(numbers: number[]) {
  let total = 0;
  for (let i = 1; i <= numbers.length; i++) {
    total += numbers[i];
  }
  return total;
}

function factorial(n: number) {
  let result = 1;
  for (let i = n; i >= 0; i--) {
    result *= i;
  }
  return result;
}

console.log(evens([1, 2, 3, 4]));
console.log(sum([1, 2, 3]));
console.log(factorial(5));
stdout
[ 1, 3 ]
NaN
0
FunctionBugFix
evensn % 2 is 1 (truthy) for odd numbers, so the filter keeps the oddsn % 2 === 0
sumStarts at index 1 and runs to length, skipping the first element and reading one past the end (undefined, hence NaN)for (let i = 0; i < numbers.length; i++), or for...of
factorialThe loop runs for i = 0, multiplying the result by zeroi > 0
Fixed
function evens(numbers: number[]) {
  return numbers.filter((n) => n % 2 === 0);
}

function sum(numbers: number[]) {
  let total = 0;
  for (const n of numbers) total += n;
  return total;
}

function factorial(n: number): number {
  if (!Number.isInteger(n) || n < 0) throw new RangeError("factorial expects a non-negative integer");
  let result = 1;
  for (let i = n; i > 1; i--) result *= i;
  return result;
}

console.log(evens([1, 2, 3, 4]));
console.log(sum([1, 2, 3]));
console.log(factorial(5), factorial(0));
stdout
[ 2, 4 ]
6
120 1
  • With noUncheckedIndexedAccess enabled, the buggy sum would not have compiled: numbers[i] would be number | undefined.
  • Iterating with for...of or reduce removes the index, and with it the whole class of off-by-one errors.
  • Other favourites in this style: a class method that reads a field without this., a property assigned in a constructor but never declared, class members separated by commas, and a function annotated to return any[] when it clearly returns number[].
FOLLOW-UPS
The snippet you are handed has no bug. What do you say?
That it is correct, and why. Some interviewers include a clean snippet on purpose; inventing a problem to have something to say is worse than confidently finding none.
typescriptlang.org
15Implement a type-safe event emitter in TypeScript.Senior
SHORT ANSWER

Make the class generic over a map from event name to payload type. on and emit are then generic over a key of that map, so the event name is checked, and the listener's parameter and the emitted payload are inferred from it.

type Listener<Payload> = (payload: Payload) => void;

class TypedEmitter<Events extends Record<string, unknown>> {
  #listeners = new Map<keyof Events, Set<Listener<never>>>();

  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
    const listeners = this.#listeners.get(event) ?? new Set();
    listeners.add(listener);
    this.#listeners.set(event, listeners);
    return () => listeners.delete(listener); // the unsubscribe function
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    const listeners = this.#listeners.get(event) as Set<Listener<Events[K]>> | undefined;
    listeners?.forEach((listener) => listener(payload));
  }
}

type AppEvents = {
  login: { userId: string };
  logout: undefined;
  "cart:add": { sku: string; quantity: number };
};

const emitter = new TypedEmitter<AppEvents>();

const unsubscribe = emitter.on("login", ({ userId }) => console.log("welcome", userId));
emitter.on("cart:add", ({ sku, quantity }) => console.log(quantity + " x " + sku));

emitter.emit("login", { userId: "u_1" });
emitter.emit("cart:add", { sku: "kbd-01", quantity: 2 });
unsubscribe();
emitter.emit("login", { userId: "u_2" }); // nobody is listening any more
stdout
welcome u_1
2 x kbd-01
What the types reject
type AppEvents = { login: { userId: string }; logout: undefined };

// The public shape of TypedEmitter<AppEvents>:
declare const emitter: {
  on<K extends keyof AppEvents>(event: K, listener: (payload: AppEvents[K]) => void): () => void;
  emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]): void;
};

emitter.emit("login", { id: "u_1" }); // error: the payload must be { userId: string }
emitter.emit("signup", { userId: "u_1" }); // error: "signup" is not a known event
emitter.on("login", (payload: { sku: string }) => {}); // error: wrong listener parameter
  • K extends keyof Events captures the specific event name, and Events[K] looks up its payload. That pairing is the whole trick.
  • Returning the unsubscribe function from on fits React's effect cleanup exactly: useEffect(() => emitter.on("login", handler), []).
  • One honest assertion. The map stores listeners for different events together, so the lookup in emit needs a cast. The public API is fully checked; the unsafety is confined to one private line.

The trap: The event map is declared with type, not interface. An interface is not assignable to Record<string, unknown> because it could be extended by declaration merging, so TypedEmitter<SomeInterface> fails the constraint.

FOLLOW-UPS
How would you make the payload optional for events that carry none?
Type the rest of the arguments as a conditional tuple: ...args: Events[K] extends undefined ? [] : [payload: Events[K]]. Then emit("logout") and emit("login", { ... }) are both checked.
What happens if a listener unsubscribes while an event is being emitted?
A Set tolerates deletion during iteration, and a listener removed before its turn is skipped. If you need 'everyone registered at the start gets called', iterate over a copy.
typescriptlang.org
16Design a parking lot using object-oriented principles in TypeScript.Mid
SHORT ANSWER

Model the two things that vary, spot kinds and vehicle kinds, as unions, and put the rules for which vehicle fits where in one data table. The lot tracks free spots per kind and hands out tickets; every query the brief asks for becomes a one-line getter.

A typical brief: the lot has motorcycle, car and large spots. A motorcycle can park anywhere, a car in a car or large spot, and a van in one large spot or three car spots. Report total and remaining spots, whether the lot is full or empty, whether a given kind of spot is full, and how many spots vans occupy. Ask about anything unstated, such as whether the van's three spots must be adjacent.

type SpotKind = "motorcycle" | "car" | "large";
type VehicleKind = "motorcycle" | "car" | "van";

/** Where each vehicle may park, in order of preference: [spot kind, how many it needs]. */
const PARKING_OPTIONS: Record<VehicleKind, readonly (readonly [SpotKind, number])[]> = {
  motorcycle: [["motorcycle", 1], ["car", 1], ["large", 1]],
  car: [["car", 1], ["large", 1]],
  van: [["large", 1], ["car", 3]],
};

interface Ticket {
  id: number;
  vehicle: VehicleKind;
  spot: SpotKind;
  count: number;
}

const total = (counts: Record<SpotKind, number>) => counts.motorcycle + counts.car + counts.large;

class ParkingLot {
  readonly #capacity: Record<SpotKind, number>;
  readonly #free: Record<SpotKind, number>;
  readonly #tickets = new Map<number, Ticket>();
  #nextId = 1;

  constructor(capacity: Record<SpotKind, number>) {
    this.#capacity = { ...capacity };
    this.#free = { ...capacity };
  }

  park(vehicle: VehicleKind): Ticket | null {
    for (const [spot, count] of PARKING_OPTIONS[vehicle]) {
      if (this.#free[spot] < count) continue;
      this.#free[spot] -= count;
      const ticket: Ticket = { id: this.#nextId++, vehicle, spot, count };
      this.#tickets.set(ticket.id, ticket);
      return ticket;
    }
    return null; // nowhere suitable
  }

  leave(ticketId: number): boolean {
    const ticket = this.#tickets.get(ticketId);
    if (!ticket) return false;
    this.#free[ticket.spot] += ticket.count;
    return this.#tickets.delete(ticketId);
  }

  get totalSpots() {
    return total(this.#capacity);
  }
  get remainingSpots() {
    return total(this.#free);
  }
  get isFull() {
    return this.remainingSpots === 0;
  }
  get isEmpty() {
    return this.#tickets.size === 0;
  }

  isFullFor(spot: SpotKind) {
    return this.#free[spot] === 0;
  }

  spotsTakenBy(vehicle: VehicleKind) {
    let taken = 0;
    for (const ticket of this.#tickets.values()) {
      if (ticket.vehicle === vehicle) taken += ticket.count;
    }
    return taken;
  }
}

const lot = new ParkingLot({ motorcycle: 1, car: 4, large: 1 });

lot.park("van"); // takes the large spot
const secondVan = lot.park("van"); // no large spot left: takes three car spots
lot.park("motorcycle");
lot.park("car");

console.log(lot.totalSpots, lot.remainingSpots);
console.log(lot.isFull, lot.isFullFor("large"));
console.log(lot.spotsTakenBy("van"));
console.log(lot.park("car"));

if (secondVan) lot.leave(secondVan.id);
console.log(lot.remainingSpots, lot.isFull);
stdout
6 0
true true
4
null
3 false
  • Rules as data. Adding a bus, or letting motorcycles share a spot, is a new row in PARKING_OPTIONS, not a new branch in park.
  • Unions over classes. A Vehicle base class with Motorcycle, Car and Van subclasses would add three files and no behaviour. Reach for classes when the variants carry different logic.
  • Encapsulation. #free and #tickets are private, so the counts cannot be put out of sync from outside; the only way to change them is park and leave.
  • The ticket is the source of truth for what must be given back, so leave cannot free the wrong number of spots.
FOLLOW-UPS
The van's three car spots must be next to each other. What changes?
Counts are no longer enough. Model each row as an array of spots and search for a run of three free ones, storing the indices on the ticket. Mention the cost: allocation becomes a scan, and fragmentation becomes possible.
How would you extend this to multiple levels, or to pricing?
Compose: a Garage holding several ParkingLot levels and trying each in turn. Pricing is a separate object that takes a ticket and a duration, so the lot does not need to know about money.
What would you test?
Each vehicle's preference order, the fallback when the preferred spot is full, leave restoring the exact count, an unknown ticket ID, and the full and empty boundaries.
typescriptlang.org

Want reps on this? Drill Hooks & Lifecycles →