React with TypeScript Interview Questions

Typing props, children, state, refs and events, why React.FC fell out of favour, generic components and typed reducers.

11 questions3 fresher, 5 mid-level, 3 senior

Most React jobs are TypeScript jobs now, so these come up even when the advert does not mention them. The good news is that the questions are narrow: interviewers want to see that you can type a component, a hook and an event handler without reaching for any, and that you know why the ergonomics are the way they are.

1.How do you type a component's props?

Fresher

Declare a type or interface for the props object and annotate the destructured parameter. That is all React needs — the return type is inferred.

type ButtonProps = {
  label: string;
  variant?: 'primary' | 'ghost';
  disabled?: boolean;
  onClick: () => void;
};

function Button({ label, variant = 'primary', onClick }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}

Likely follow-up questions

  • Should the props type be exported?

2.Should you use type or interface for props?

Mid-level

Either works. Interfaces can be merged and extended and give slightly nicer errors for object shapes; type aliases can express unions, intersections and mapped types. Most teams pick one for consistency, and type is the more common default in React code.

  • interface supports declaration merging, which is useful for augmenting a library's types and unwelcome in application code where two definitions silently combining is a bug.
  • type is required when the props are a union, for example a discriminated union of two variants of the same component.
  • Both support extension: interface A extends B and type A = B & { ... }.
A shape a plain interface cannot express.
type InputProps =
  | { multiline: true; rows: number }
  | { multiline?: false; rows?: never };

Likely follow-up questions

  • When could you not use an interface?

3.How do you type children?

Fresher

Use React.ReactNode, which covers elements, strings, numbers, arrays, null and undefined. Use React.ReactElement only when the child must be a single element.

type PanelProps = {
  title: string;
  children: React.ReactNode;   // anything renderable
};

type TabsProps = {
  children: React.ReactElement<TabProps>[]; // must be Tab elements
};

type ToggleProps = {
  children: (isOpen: boolean) => React.ReactNode; // render prop
};

ReactNode is the right default. JSX.Element is narrower than people expect and rejects a string child, which is usually not what you want.

Likely follow-up questions

  • What is the difference between ReactNode and JSX.Element?

4.What is React.FC and why do many teams avoid it?

Mid-level

React.FC is a type for function components that annotates the whole function rather than its props. Teams moved away from it because it used to add an implicit children prop, it complicates generic components, and annotating the parameter is simpler.

// Common today
function Card({ title }: CardProps) { ... }

// The older style
const Card: React.FC<CardProps> = ({ title }) => { ... };
  • Before React 18's types, React.FC added children to every component, so a component that accepted no children still typed as if it did. That is fixed, but the habit stuck.
  • A generic component cannot be expressed cleanly with React.FC without awkward casts.
  • Annotating the parameter gives the same safety with less indirection.

It is not wrong to use it, and a codebase that already does should stay consistent. This question is really about whether you can explain a convention rather than just follow one.

Likely follow-up questions

  • What changed in the React 18 type definitions?

5.How do you type useState, and when is the type argument needed?

Fresher

TypeScript infers the type from the initial value, so most calls need no annotation. You need the type argument when the initial value does not represent the full range, typically null or an empty array.

const [count, setCount] = useState(0);              // number, inferred
const [user, setUser] = useState<User | null>(null); // otherwise it is just null
const [items, setItems] = useState<Item[]>([]);      // otherwise never[]

// A state machine, typed as a union
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');

Likely follow-up questions

  • Why does an empty array infer never[]?

6.How do you type an event handler?

Mid-level

Use React's generic event types parameterised by the element: React.ChangeEvent<HTMLInputElement>, React.MouseEvent<HTMLButtonElement>, React.FormEvent<HTMLFormElement>. Inline handlers usually infer these for you.

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  setValue(event.target.value); // typed as string
}

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
}

// Inline: no annotation needed, the type flows from the element
<input onChange={(event) => setValue(event.target.value)} />

The element type argument is what makes event.target.value type as string rather than erroring. Getting this wrong is the reason people reach for any here, so being specific about it is a good signal.

Likely follow-up questions

  • Why is event.target sometimes typed as EventTarget?

7.How do you type useRef?

Mid-level

For a DOM node use useRef<HTMLInputElement>(null), which gives a read-only current that may be null. For a mutable value use useRef<number | undefined>(undefined), which gives a writable current.

// DOM ref: React sets it, so current is typed as possibly null
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();

// Mutable instance value: you set it, so current is writable
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
timerRef.current = setTimeout(save, 500);

The two overloads behave differently on purpose: passing null as the initial value and a DOM type produces a RefObject, whose current React owns; anything else produces a MutableRefObject you can assign to.

Likely follow-up questions

  • Why is current read-only in one case and not the other?

8.How do you type a component that accepts all the native props of an element?

Senior

Extend the element's prop type from React's JSX intrinsic elements, or use ComponentPropsWithoutRef, then add your own props on top and spread the rest onto the element.

type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
  variant?: 'primary' | 'ghost';
};

function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
  return <button className={`${variant} ${className ?? ''}`} {...rest} />;
}

Use ComponentPropsWithRef when the component forwards a ref. The pattern matters for design systems: consumers get aria-*, data-*, type, disabled and every other native attribute without you enumerating them.

Likely follow-up questions

  • How would you type this if the component could render as an anchor instead?

9.How do you write a generic component?

Senior

Declare a type parameter on the function and use it in the props, so the caller's data type flows through to the callbacks and the rendered items.

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string;
  renderItem: (item: T) => React.ReactNode;
};

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

// T is inferred as User, so item is typed inside renderItem
<List items={users} getKey={(u) => u.id} renderItem={(u) => u.name} />

Likely follow-up questions

  • Why does the trailing comma matter?

10.How do you type useReducer and its actions?

Senior

Model the actions as a discriminated union on a type field and type the reducer as (state: State, action: Action) => State. TypeScript then narrows the payload inside each case and errors on an unhandled action.

type State = { items: Item[]; status: 'idle' | 'saving' };

type Action =
  | { type: 'added'; item: Item }
  | { type: 'removed'; id: string }
  | { type: 'saving' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'added':
      return { ...state, items: [...state.items, action.item] }; // action.item is typed
    case 'removed':
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case 'saving':
      return { ...state, status: 'saving' };
  }
}

With the return type annotated and no default case, TypeScript reports a missing branch when a new action is added, which turns the reducer into a checklist that maintains itself.

Likely follow-up questions

  • What is exhaustiveness checking with never?
Practise this in the quiz

11.How should you type data coming back from an API?

Mid-level

Do not assert it. A response is unknown until something checks it, so validate it at the boundary with a schema library such as Zod and let the validated type flow inwards.

const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return UserSchema.parse(await response.json()); // throws if the shape is wrong
}

Likely follow-up questions

  • What is the difference between unknown and any?
  • Where should validation live in the app?

All React interview questions