Skip to content

5. TypeScript with React

The practical part. Most React + TypeScript interview questions come from this page.


1. Typing Props

tsx
interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary";
  disabled?: boolean;
  onClick: () => void;
  children?: React.ReactNode;
}

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

React.FC — Don't Bother

tsx
// Older style, now discouraged
const Button: React.FC<ButtonProps> = ({ label }) => <button>{label}</button>;

// Preferred
function Button({ label }: ButtonProps) { … }

Reasons React.FC fell out of favour: it used to implicitly add children (removed in React 18 types), it makes generic components awkward, and it adds nothing you don't get from annotating the parameter.

Interview Point

Being able to say "React.FC implicitly added children, which was removed in the React 18 types, so plain function annotation is now the convention" is a small detail that lands well.


2. Children Types

tsx
interface Props {
  children: React.ReactNode;        // anything renderable — the default choice
  icon: React.ReactElement;         // exactly one element
  render: (item: T) => React.ReactNode;   // render prop
}
TypeAccepts
React.ReactNodeelement, string, number, array, null, undefined, boolean
React.ReactElementa JSX element only
JSX.Elementa JSX element only (slightly narrower)

Default to React.ReactNode unless you need to inspect or clone the child.


3. Extending HTML Element Props

The pattern every design-system component uses.

tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "ghost";
  isLoading?: boolean;
}

function Button({ variant = "primary", isLoading, ...rest }: ButtonProps) {
  return <button className={variant} disabled={isLoading} {...rest} />;
}

// Consumers get every native button prop for free
<Button onClick={…} type="submit" aria-label="Save" variant="ghost" />

Other Useful Ones

tsx
React.InputHTMLAttributes<HTMLInputElement>
React.AnchorHTMLAttributes<HTMLAnchorElement>
React.FormHTMLAttributes<HTMLFormElement>
React.HTMLAttributes<HTMLDivElement>           // generic div props
React.ComponentProps<typeof SomeComponent>      // steal another component's props
React.ComponentPropsWithoutRef<"button">        // native props minus ref

React.ComponentProps<typeof Button> is very handy — it extracts the props of a component you don't control.

Overriding a Conflicting Prop

tsx
interface InputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
  size?: "sm" | "md" | "lg";   // our size, not the native numeric one
}

4. Typing Hooks

useState

tsx
const [count, setCount] = useState(0);                    // inferred: number
const [user, setUser] = useState<User | null>(null);      // explicit union
const [items, setItems] = useState<string[]>([]);         // empty array needs help

Without the annotation, useState([]) infers never[] and you cannot push anything into it.

useRef

Three variants, and interviewers ask about the difference.

tsx
// DOM ref — read-only .current, initialised to null
const inputRef = useRef<HTMLInputElement>(null);

// Mutable value — you assign to .current
const timerRef = useRef<number | null>(null);

// Mutable with no initial value
const dataRef = useRef<Data>();   // Data | undefined
tsx
inputRef.current?.focus();   // optional chaining — it may be null before mount

useReducer

tsx
type State = { count: number; step: number };

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

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.payload };
    default: {
      const _exhaustive: never = action;
      return state;
    }
  }
}

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

dispatch({ type: "setStep", payload: 5 });   // payload required and typed
// dispatch({ type: "increment", payload: 5 });   // Error — no payload allowed

A discriminated union for actions is the whole point. It makes each action's payload requirements exact.

useContext

tsx
interface AuthContextValue {
  user: User | null;
  login: (creds: Credentials) => Promise<void>;
  logout: () => void;
}

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

export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
  return ctx;   // narrowed to non-null for every consumer
}

Initialising to null plus a throwing custom hook is the idiomatic pattern. It gives consumers a non-nullable type and a clear runtime error if the provider is missing.

Custom Hooks — Return as const

tsx
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((p) => !p), []);
  return [on, toggle] as const;
}

const [isOpen, toggleOpen] = useToggle();
// isOpen: boolean, toggleOpen: () => void

Without as const the return widens to (boolean | (() => void))[] and destructuring is useless.

For more than two values, return an object instead — position stops being readable.


5. Typing Events

tsx
// Click
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();
};

// Change
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setValue(e.target.value);
};

// Select and textarea
const onSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {};
const onTextarea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {};

// Form submit
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const formData = new FormData(e.currentTarget);
};

// Keyboard
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === "Enter") submit();
};

// Focus
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {};

Inline Handlers Are Inferred

tsx
<input onChange={(e) => setValue(e.target.value)} />   // e is inferred

You only need the annotation when the handler is defined outside JSX.

target vs currentTarget

currentTarget is the element the handler is attached to and is correctly typed. target is whatever was actually clicked and is typed as the generic EventTarget. Prefer currentTarget when you need the element.


6. Typing Refs and forwardRef

React 19 — ref Is a Normal Prop

tsx
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  ref?: React.Ref<HTMLInputElement>;
}

function Input({ ref, ...props }: InputProps) {
  return <input ref={ref} {...props} />;
}

React 18 and Earlier — forwardRef

tsx
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, ...props }, ref) => <input ref={ref} {...props} />
);

Input.displayName = "Input";

Note the order: forwardRef<ElementType, PropsType> — element first, props second. Getting it backwards is a classic mistake.


7. Generic Components

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

function Select<T>({ options, value, onChange, getLabel, getKey }: SelectProps<T>) {
  return (
    <ul>
      {options.map((o) => (
        <li key={getKey(o)} onClick={() => onChange(o)}>
          {getLabel(o)}
        </li>
      ))}
    </ul>
  );
}

<Select
  options={users}
  value={selected}
  onChange={setSelected}   // typed as (u: User) => void
  getLabel={(u) => u.name} // u inferred as User
  getKey={(u) => u.id}
/>

Everything infers from options. This is the single best demonstration of "generics preserve relationships" in a React context.


8. Discriminated Union Props

Make impossible prop combinations un-writable.

tsx
type AlertProps =
  | { variant: "error"; errorCode: number; message: string }
  | { variant: "success"; message: string }
  | { variant: "loading" };

function Alert(props: AlertProps) {
  if (props.variant === "error") {
    return <div>Error {props.errorCode}: {props.message}</div>;
  }
  if (props.variant === "success") {
    return <div>{props.message}</div>;
  }
  return <Spinner />;
}

<Alert variant="error" errorCode={404} message="Not found" />;   // OK
// <Alert variant="loading" errorCode={404} />;   // Error
tsx
type Props =
  | ({ as: "button" } & React.ButtonHTMLAttributes<HTMLButtonElement>)
  | ({ as: "a"; href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>);

href is required when as="a" and forbidden when as="button".


9. Typing API Data

The most important practical point.

tsx
// ❌ A lie — res.json() returns `any`
const user: User = await res.json();

// ✅ Validate at the boundary
import { z } from "zod";

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

type User = z.infer<typeof UserSchema>;

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return UserSchema.parse(await res.json());
}

With React Query

tsx
const { data } = useQuery({
  queryKey: ["user", id],
  queryFn: () => getUser(id),
});
// data: User | undefined — the undefined is correct, it isn't loaded yet

The | undefined is not an annoyance to assert away. It is TypeScript correctly telling you to handle the loading state.


10. Typing Forms

tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 characters"),
  remember: z.boolean().default(false),
});

type FormValues = z.infer<typeof schema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ resolver: zodResolver(schema) });

  const onSubmit = async (values: FormValues) => {
    await login(values);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <button disabled={isSubmitting}>Log in</button>
    </form>
  );
}

One schema gives you: runtime validation, error messages, the TypeScript type, and typed field names in register("email") — a typo there is a compile error.


11. Common Errors and Fixes

ErrorCauseFix
Type 'null' is not assignable to type 'HTMLInputElement'Ref used before mountref.current?.focus()
Property 'x' does not exist on type 'never'useState([]) inferred never[]useState<string[]>([])
Type '{}' is missing properties…Context default is {}Use createContext<T | null>(null) + guard hook
JSX element type does not have construct signaturesReturned an array without a FragmentWrap in <>…</>
Cannot find name 'T' in .tsx<T> parsed as JSX<T,> or <T extends unknown>
Object is possibly 'undefined'Correct — data may not be loadedHandle it; don't add !
Type 'string' is not assignable to '"a" | "b"'Value widened to stringas const or satisfies

12. tsconfig for a React Project

json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "noEmit": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}

The Two Worth Explaining

strict: true — non-negotiable. strictNullChecks alone eliminates most runtime type errors.

noUncheckedIndexedAccess — makes arr[0] return T | undefined, which is the truth. Off by default because it is noisy, but it catches a genuine class of bug. Mentioning it signals you have thought about this beyond copying a template.

jsx: "react-jsx" — the React 17+ transform, so you don't need import React just for JSX.

© 2025 DDocs · Dipak's Documentation Guide