5. TypeScript with React
The practical part. Most React + TypeScript interview questions come from this page.
1. Typing Props
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
// 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
interface Props {
children: React.ReactNode; // anything renderable — the default choice
icon: React.ReactElement; // exactly one element
render: (item: T) => React.ReactNode; // render prop
}| Type | Accepts |
|---|---|
React.ReactNode | element, string, number, array, null, undefined, boolean |
React.ReactElement | a JSX element only |
JSX.Element | a 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.
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
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 refReact.ComponentProps<typeof Button> is very handy — it extracts the props of a component you don't control.
Overriding a Conflicting Prop
interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
size?: "sm" | "md" | "lg"; // our size, not the native numeric one
}4. Typing Hooks
useState
const [count, setCount] = useState(0); // inferred: number
const [user, setUser] = useState<User | null>(null); // explicit union
const [items, setItems] = useState<string[]>([]); // empty array needs helpWithout the annotation, useState([]) infers never[] and you cannot push anything into it.
useRef
Three variants, and interviewers ask about the difference.
// 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 | undefinedinputRef.current?.focus(); // optional chaining — it may be null before mountuseReducer
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 allowedA discriminated union for actions is the whole point. It makes each action's payload requirements exact.
useContext
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
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: () => voidWithout 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
// 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
<input onChange={(e) => setValue(e.target.value)} /> // e is inferredYou 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
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
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
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.
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} />; // ErrorAnother Common One — Link vs Button
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.
// ❌ 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
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => getUser(id),
});
// data: User | undefined — the undefined is correct, it isn't loaded yetThe | undefined is not an annoyance to assert away. It is TypeScript correctly telling you to handle the loading state.
10. Typing Forms
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
| Error | Cause | Fix |
|---|---|---|
Type 'null' is not assignable to type 'HTMLInputElement' | Ref used before mount | ref.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 signatures | Returned an array without a Fragment | Wrap 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 loaded | Handle it; don't add ! |
Type 'string' is not assignable to '"a" | "b"' | Value widened to string | as const or satisfies |
12. tsconfig for a React Project
{
"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.