TypeScript Essentials for React

React & Next.js: The Complete Developer HandbookLesson 16 of 54

Part 1: React Foundations · 16 of 16

Goal: read and write typed React components with confidence: describe the tool library’s data with types and unions, type props, state, events and a reducer, and check data from outside your app instead of trusting a type cast.

From Lesson 17 on, every example in this course is TypeScript, because that is how most React and Next.js projects are written today. This lesson is not a full TypeScript course. It covers the part you meet every day in React code, using the tool library’s own data, so that the next part of the course reads naturally. For everything else, the TypeScript Handbook is the place to go.

What TypeScript does, and what it does not

TypeScript is JavaScript with type annotations. You describe the shape of your data and the contract of each function and component, and the TypeScript compiler (tsc) and your editor check every use against that description before the code runs. A typo in a property name, a missing prop or a status that does not exist is underlined as you type, instead of turning up as undefined on a member’s screen.

Two facts to keep straight from the start:

  • Types disappear when the code runs. Before the browser sees your code, the build removes every annotation. So types cannot check anything at runtime: data from an API, a form or localStorage is whatever it is, whatever type you wrote for it. The last section of this lesson deals with that.
  • The live editor on this site removes types without checking them. You can run every example below, but a type error will not be reported here. To see the errors this lesson quotes, paste the code into a TypeScript project in your editor (Lesson 18 sets one up) or the TypeScript Playground. In a real project, your editor shows errors as you type, and tsc --noEmit (or next build, which runs the type check) catches anything you missed.

React component files use the .tsx extension (TypeScript plus JSX); files without JSX use .ts. The error messages quoted in this lesson come from TypeScript 5.9 with strict mode on, which is the setting new projects use and the one you should keep. Newer TypeScript releases (version 7 is out) may word a message slightly differently, but they catch the same mistakes.

Describing the data: types and unions

Start with the data, because everything else refers to it. A type gives a name to the shape of an object:

type ToolStatus = 'available' | 'reserved' | 'on-loan' | 'in-repair';

type Tool = {
  id: string;
  slug: string;
  name: string;
  category: string;
  status: ToolStatus;
  maxLoanDays: number;
};

type Reservation = {
  id: string;
  toolId: Tool['id'];
  memberName: string;
  startDate: string; // 'YYYY-MM-DD'
  endDate: string;
  note?: string; // Optional: may be missing.
};

ToolStatus is a union of string literals: a value of this type must be exactly one of those four strings. This is one of the most useful things TypeScript offers React developers, because so much UI depends on a value being one of a fixed set. Write status: 'onloan' and the compiler answers:

Type '"onloan"' is not assignable to type 'ToolStatus'. Did you mean '"on-loan"'?

A few more pieces of syntax appear above. note?: string marks an optional property: it may be missing, and when you read it its type is string | undefined. Tool['id'] means “whatever type Tool‘s id has”, which keeps the two in step if it ever changes. You will also see interface Tool { … } in other code; for describing objects it does almost the same job. Pick one style for a project and stay with it; this course uses type.

Unions pair well with Record, which describes an object with a value for every key of a union:

const STATUS_LABELS: Record<ToolStatus, string> = {
  available: 'Available',
  reserved: 'Reserved',
  'on-loan': 'On loan',
  'in-repair': 'In repair',
};

Delete the 'in-repair' line and the compiler reports that property "in-repair" is missing. When someone adds a fifth status next year, every label table that forgot it fails to compile, instead of showing an empty badge in production.

Typing props and children

A component’s props are a single object, so you type them like any object, usually with a Props type next to the component. Run this and click the drill’s Reserve button:

import type { ReactNode } from 'react';

type ToolStatus = 'available' | 'reserved' | 'on-loan' | 'in-repair';
type Tool = { id: string; name: string; category: string; status: ToolStatus };

const STATUS_LABELS: Record<ToolStatus, string> = {
  available: 'Available', reserved: 'Reserved', 'on-loan': 'On loan', 'in-repair': 'In repair',
};

type ToolCardProps = {
  tool: Tool;
  onReserve: (toolId: string) => void;
  children?: ReactNode;
};

function ToolCard({ tool, onReserve, children }: ToolCardProps) {
  return (
    <article>
      <h3>{tool.name}</h3>
      <p>{tool.category}: {STATUS_LABELS[tool.status]}</p>
      <button disabled={tool.status !== 'available'} onClick={() => onReserve(tool.id)}>
        Reserve
      </button>
      {children}
    </article>
  );
}

const TOOLS: Tool[] = [
  { id: 't1', name: 'Cordless drill', category: 'Power tools', status: 'available' },
  { id: 't2', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan' },
];

function App() {
  return (
    <>
      {TOOLS.map((tool) => (
        <ToolCard key={tool.id} tool={tool} onReserve={(id) => console.log('Reserve', id)}>
          {tool.status === 'on-loan' && <p>Ask a volunteer when it is due back.</p>}
        </ToolCard>
      ))}
    </>
  );
}

The console shows “Reserve t1”. The annotation goes after the destructured parameter: ({ tool, onReserve, children }: ToolCardProps). What you get for it:

  • Required props are enforced. <ToolCard tool={tool} /> fails with “Property ‘onReserve’ is missing in type ‘{ tool: Tool; }’ but required in type ‘ToolCardProps’.”
  • Callback props have a signature. (toolId: string) => void says the card will call it with a string and ignore anything it returns. The parent’s (id) => … does not need an annotation: TypeScript infers id is a string from the prop type.
  • **children is a ReactNode**, the type for anything React can render: elements, strings, numbers, null, false and arrays of them. Mark it optional (?) when the component also makes sense without children.
  • No return type needed. TypeScript infers that a component returns JSX. You may also see components typed as React.FC<Props>; it works, but the plain function form is simpler and is what this course uses.

import type imports something that only exists for the type checker. It is removed completely from the built code, which keeps the intent clear. When a component wraps a native element, borrow that element’s props instead of retyping them: type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'secondary' }; gives your Button every attribute a real <button> accepts (type, disabled, onClick, aria-*…) plus your own variant.

Typing state, refs and events

Most of the time useState needs no annotation, because TypeScript infers the type from the initial value: useState('') is a string, useState(7) a number. You add a type argument in angle brackets when the initial value does not tell the whole story. Two cases come up constantly:

// An empty array: without <string[]>, TypeScript infers never[], an array that can hold nothing.
const [basketIds, setBasketIds] = useState<string[]>([]);

// Nothing selected yet: the state is a Tool or null.
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);

Forget the type argument on the empty array and the first setBasketIds([...basketIds, 't1']) fails with “Type ‘string’ is not assignable to type ‘never’”. With Tool | null, TypeScript makes you deal with the empty case. selectedTool.name is an error (“‘selectedTool’ is possibly ‘null’”) until you check, and after the check TypeScript knows the value is a Tool. This is called narrowing, and it is how TypeScript prevents the classic “cannot read properties of null” crash:

import { useRef, useState, type ChangeEvent, type SubmitEvent } from 'react';

type Tool = { id: string; name: string; maxLoanDays: number };

const TOOLS: Tool[] = [
  { id: 't1', name: 'Cordless drill', maxLoanDays: 7 },
  { id: 't6', name: 'Tile cutter', maxLoanDays: 7 },
  { id: 't8', name: 'Lawn aerator', maxLoanDays: 5 },
];

function ToolSearch() {
  const [query, setQuery] = useState('');
  const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

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

  function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
    event.preventDefault();
    const match = TOOLS.find((tool) => tool.name.toLowerCase().includes(query.toLowerCase()));
    setSelectedTool(match ?? null);
    inputRef.current?.focus();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>Find a tool <input ref={inputRef} value={query} onChange={handleChange} /></label>{' '}
      <button>Search</button>
      {selectedTool === null
        ? <p>No tool selected.</p>
        : <p>{selectedTool.name}: up to {selectedTool.maxLoanDays} days.</p>}
    </form>
  );
}

Type “aer” and press Search: it shows “Lawn aerator: up to 5 days.” Press it with “xyz”: “No tool selected.” The other annotations in this example:

  • Refs to DOM elements are typed with the element type and start as null: useRef<HTMLInputElement>(null). React fills them in after rendering, so you read them with optional chaining, inputRef.current?.focus().
  • Event handlers defined separately need the event type. React exports one per kind of event: ChangeEvent<HTMLInputElement> for an input’s onChange, SubmitEvent<HTMLFormElement> for a form’s onSubmit, MouseEvent<HTMLButtonElement> for a click, KeyboardEvent for keys. The type argument is the element the handler is attached to, which is why event.target.value is known to be a string. Older code and tutorials use FormEvent for submit handlers; the latest React type definitions mark it as deprecated in favour of SubmitEvent.
  • Inline handlers need nothing. In onChange={(e) => setQuery(e.target.value)}, TypeScript already knows the type of e from the onChange prop. If you are unsure which event type to write, hover over the prop in your editor and it tells you.

A reducer with a discriminated union

Lesson 10 moved the basket into a reducer with four actions. In TypeScript, the actions become a union of object types that all share a type field with a different literal value. This is called a discriminated union, and it fits reducers perfectly:

type BasketAction =
  | { type: 'added'; toolId: string }
  | { type: 'removed'; toolId: string }
  | { type: 'cleared' }
  | { type: 'datesChanged'; startDate: string; endDate: string };

Inside switch (action.type), each case narrows action to one member of the union: in case 'added' TypeScript knows action.toolId exists, and in case 'cleared' it knows there is nothing else on the action. Dispatching a malformed action is caught where you write it. dispatch({ type: 'added' }) reports that property ‘toolId’ is missing, and dispatch({ type: 'add', toolId: 't1' }) reports that "add" is not one of "added" | "removed" | "cleared" | "datesChanged".

import { useReducer } from 'react';

type BasketState = { toolIds: string[]; startDate: string; endDate: string };
type BasketAction =
  | { type: 'added'; toolId: string }
  | { type: 'removed'; toolId: string }
  | { type: 'cleared' }
  | { type: 'datesChanged'; startDate: string; endDate: string };

const MAX_TOOLS = 3;

function basketReducer(state: BasketState, action: BasketAction): BasketState {
  switch (action.type) {
    case 'added':
      if (state.toolIds.includes(action.toolId) || state.toolIds.length >= MAX_TOOLS) return state;
      return { ...state, toolIds: [...state.toolIds, action.toolId] };
    case 'removed':
      return { ...state, toolIds: state.toolIds.filter((id) => id !== action.toolId) };
    case 'cleared':
      return { ...state, toolIds: [] };
    case 'datesChanged':
      return { ...state, startDate: action.startDate, endDate: action.endDate };
    default: {
      const unhandled: never = action;
      throw new Error(`Unhandled action: ${JSON.stringify(unhandled)}`);
    }
  }
}

const initialBasket: BasketState = { toolIds: [], startDate: '', endDate: '' };

function Basket() {
  const [basket, dispatch] = useReducer(basketReducer, initialBasket);
  const full = basket.toolIds.length >= MAX_TOOLS;

  return (
    <div>
      {['t1', 't3', 't6', 't7'].map((id) => (
        <button key={id} disabled={full} onClick={() => dispatch({ type: 'added', toolId: id })}
          style={{ marginRight: 8 }}>
          Add {id}
        </button>
      ))}
      <button onClick={() => dispatch({ type: 'cleared' })}>Clear</button>
      <p>Basket: {basket.toolIds.join(', ') || 'empty'} ({basket.toolIds.length} of {MAX_TOOLS})</p>
    </div>
  );
}

Add tools until the basket holds three: the add buttons disable and the count stops at “3 of 3”. Clear empties it. The reducer’s annotations, (state: BasketState, action: BasketAction): BasketState, are all useReducer needs; it infers the type of basket and of dispatch from them, so the component has no annotations at all.

The default branch is an exhaustiveness check. If every action type has a case, nothing can reach default, so action there has the type never (no possible value) and the assignment is fine. Add a fifth action to the union and forget its case, and the assignment fails to compile, with a message saying the new action type is not assignable to never. The compiler reminds you of every reducer you need to update.

Data from outside: unknown and type guards

Types describe what you expect; they do not make it true. Data that crosses into your app from outside (an API response, JSON.parse, localStorage, a URL parameter) can be anything. The tempting shortcut is a type assertion:

const tools = (await response.json()) as Tool[];

as does not check or convert anything. It tells the compiler “trust me”, and from then on every part of your code trusts that the data is right. If the API sends "maxLoanDays": "7" (a string) or a status your union does not know, the mistake travels deep into the UI before anything notices.

The safer pattern: give outside data the type unknown, which TypeScript will not let you use until you have checked it, and check it with a type guard: a function returning value is Tool, which tells TypeScript that when the function returns true, the value really is a Tool. In this example the pretend API returns two records, and the second has two problems:

import { useState } from 'react';

type ToolStatus = 'available' | 'reserved' | 'on-loan' | 'in-repair';
type Tool = { id: string; name: string; status: ToolStatus; maxLoanDays: number };
const STATUSES: string[] = ['available', 'reserved', 'on-loan', 'in-repair'];

function isTool(value: unknown): value is Tool {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>; // Every field is still unknown here.
  return typeof v.id === 'string' && typeof v.name === 'string'
    && typeof v.status === 'string' && STATUSES.includes(v.status)
    && typeof v.maxLoanDays === 'number';
}

// Pretend API: the second record uses a different format.
function fetchToolsJson(): Promise<unknown> {
  const body = `[{"id":"t1","name":"Cordless drill","status":"available","maxLoanDays":7},
    {"id":"t6","name":"Tile cutter","status":"Available","maxLoanDays":"7"}]`;
  return new Promise((resolve) => setTimeout(() => resolve(JSON.parse(body)), 600));
}

function App() {
  const [trusted, setTrusted] = useState<Tool[]>([]);
  const [checked, setChecked] = useState<Tool[]>([]);

  async function load() {
    const data = await fetchToolsJson();
    setTrusted(data as Tool[]); // Compiles, checks nothing.
    setChecked(Array.isArray(data) ? data.filter(isTool) : []);
  }

  const row = (tool: Tool) => (
    <li key={tool.id}>{tool.name}: {tool.status}, longest loan + 1 day = {tool.maxLoanDays + 1}</li>
  );

  return (
    <div>
      <button onClick={load}>Load catalogue</button>
      <h3>Trusting the cast</h3><ul>{trusted.map(row)}</ul>
      <h3>Checked with isTool</h3><ul>{checked.map(row)}</ul>
    </div>
  );
}

Click Load catalogue. Under “Trusting the cast”, the tile cutter shows the status “Available” (not one of our four values) and “longest loan + 1 day = 71”: the string "7" plus 1 is string concatenation. Under “Checked with isTool”, the tile cutter is simply left out. In a real app you would also log the rejected record and perhaps show a message, but the bad data never reaches components that rely on the Tool type.

Notice the single as inside isTool: casting to Record<string, unknown> claims nothing about the contents, it only lets you read properties so you can test each one. That is the kind of assertion that is safe. Writing guards by hand for large shapes gets tedious, so real projects usually describe the shape once with a schema library such as Zod, which checks the data and produces the type from the same definition. Lesson 37 uses one for form validation, and Lesson 42 for API responses.

Relatedly, avoid any. A value typed any switches type checking off, both for it and for everything it flows into. unknown is the honest type for “I don’t know yet”: it forces the check that any lets you skip.

Common mistakes

  • **Using as to make an error go away.** data as Tool[] or {} as Tool silences the compiler without changing the data. In review, every as on outside data should make you ask “where is this checked?” The safe uses are rare: narrowing unknown to a record to inspect it, or as const.
  • **Reaching for any.** A function parameter typed any, or a // @ts-ignore above an error, spreads unchecked values through the code. Use unknown and narrow it, or fix the type that is wrong.
  • Treating types as validation. A Reservation type does not stop a request with an end date before the start date, or a member reserving a fourth tool. Business rules and security checks still run in code, on the server (Lessons 37 and 39).
  • Over-engineering types. Deeply nested generics and clever conditional types that nobody on the team can read cost more than they save. Prefer plain object types and unions; let inference do the work inside components.
  • **Typing an empty useState([]) without a type argument.** It infers never[] and every update fails with a confusing message. Write useState<Tool[]>([]).

Build it: a typed reservations list

The starter below is plain JavaScript: a volunteer’s list of upcoming reservations, loaded from a pretend API, with a reducer for cancelling them. It runs, but nothing is typed. Convert it to TypeScript:

  • A Reservation type with id, toolId, memberName, startDate, endDate (all strings) and an optional note.
  • A discriminated union ReservationAction for the loaded and cancelled actions, and a typed reducer with an exhaustiveness check.
  • Typed props for ReservationRow, including the onCancel callback.
  • An isReservation type guard, used to filter the API data instead of trusting it. The pretend API includes one broken record; it must not appear.
import { useReducer } from 'react';

function fetchReservationsJson() {
  const body = `[{"id":"r1","toolId":"t1","memberName":"Amina","startDate":"2026-12-01","endDate":"2026-12-03"},
    {"id":"r2","toolId":"t8","memberName":"Owen","startDate":"2026-12-02","endDate":"2026-12-04","note":"Front lawn only"},
    {"id":"r3","toolId":"t6","memberName":null,"startDate":"2026-12-05"}]`;
  return new Promise((resolve) => setTimeout(() => resolve(JSON.parse(body)), 600));
}

function reservationsReducer(state, action) {
  switch (action.type) {
    case 'loaded':
      return action.reservations;
    case 'cancelled':
      return state.filter((reservation) => reservation.id !== action.id);
  }
}

function ReservationRow({ reservation, onCancel }) {
  return (
    <li>
      {reservation.memberName}: {reservation.toolId}, {reservation.startDate} to {reservation.endDate}
      {reservation.note && ` (${reservation.note})`}{' '}
      <button onClick={() => onCancel(reservation.id)}>Cancel</button>
    </li>
  );
}

function App() {
  const [reservations, dispatch] = useReducer(reservationsReducer, []);

  async function load() {
    const data = await fetchReservationsJson();
    dispatch({ type: 'loaded', reservations: data });
  }

  return (
    <div>
      <button onClick={load}>Load reservations</button>
      <ul>
        {reservations.map((r) => (
          <ReservationRow key={r.id} reservation={r} onCancel={(id) => dispatch({ type: 'cancelled', id })} />
        ))}
      </ul>
    </div>
  );
}

Hint: give fetchReservationsJson the return type Promise<unknown>, and the compiler will refuse to dispatch the data until you have filtered it with your guard. For the optional note, accept a missing value or a string, but nothing else.

Show a sample solution
import { useReducer } from 'react';

type Reservation = {
  id: string;
  toolId: string;
  memberName: string;
  startDate: string;
  endDate: string;
  note?: string;
};

type ReservationAction =
  | { type: 'loaded'; reservations: Reservation[] }
  | { type: 'cancelled'; id: string };

function isReservation(value: unknown): value is Reservation {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return ['id', 'toolId', 'memberName', 'startDate', 'endDate'].every((key) => typeof v[key] === 'string')
    && (v.note === undefined || typeof v.note === 'string');
}

function fetchReservationsJson(): Promise<unknown> {
  const body = `[{"id":"r1","toolId":"t1","memberName":"Amina","startDate":"2026-12-01","endDate":"2026-12-03"},
    {"id":"r2","toolId":"t8","memberName":"Owen","startDate":"2026-12-02","endDate":"2026-12-04","note":"Front lawn only"},
    {"id":"r3","toolId":"t6","memberName":null,"startDate":"2026-12-05"}]`;
  return new Promise((resolve) => setTimeout(() => resolve(JSON.parse(body)), 600));
}

function reservationsReducer(state: Reservation[], action: ReservationAction): Reservation[] {
  switch (action.type) {
    case 'loaded':
      return action.reservations;
    case 'cancelled':
      return state.filter((reservation) => reservation.id !== action.id);
    default: {
      const unhandled: never = action;
      throw new Error(`Unhandled action: ${JSON.stringify(unhandled)}`);
    }
  }
}

type ReservationRowProps = {
  reservation: Reservation;
  onCancel: (id: string) => void;
};

function ReservationRow({ reservation, onCancel }: ReservationRowProps) {
  return (
    <li>
      {reservation.memberName}: {reservation.toolId}, {reservation.startDate} to {reservation.endDate}
      {reservation.note && ` (${reservation.note})`}{' '}
      <button onClick={() => onCancel(reservation.id)}>Cancel</button>
    </li>
  );
}

function App() {
  const [reservations, dispatch] = useReducer(reservationsReducer, []);

  async function load() {
    const data = await fetchReservationsJson();
    const valid = Array.isArray(data) ? data.filter(isReservation) : [];
    dispatch({ type: 'loaded', reservations: valid });
  }

  return (
    <div>
      <button onClick={load}>Load reservations</button>
      <ul>
        {reservations.map((r) => (
          <ReservationRow key={r.id} reservation={r} onCancel={(id) => dispatch({ type: 'cancelled', id })} />
        ))}
      </ul>
    </div>
  );
}

After Load reservations, only Amina’s and Owen’s reservations appear; the third record has no member name and no end date, so isReservation rejects it. In the starter, the same record shows up as a row reading “: t6, 2026-12-05 to”, with no member name and no end date. useReducer needs no type argument: it infers the state and the actions from the reducer’s annotations, and the empty initial array is accepted as Reservation[] for the same reason. This solution passes tsc --noEmit in strict mode; paste it into a TypeScript project or the Playground, change 'cancelled' in the onCancel callback to 'canceled', and see the compiler catch it.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. The API’s TypeScript type says maxLoanDays: number, yet the page shows “71” for a 7-day loan plus one. How can that happen in a TypeScript project, and how would you prevent it?

Types are removed before the code runs, so they cannot check data arriving at runtime. Somewhere the response was asserted with as (or typed as any) without being checked, and the API actually sent the string "7", so + 1 concatenated. Treat outside data as unknown, check it with a type guard or a schema library at the point where it enters the app, and reject or report records that do not match. A test that feeds the loader a malformed record and expects it to be rejected catches the problem before members do.

2. Why is a discriminated union a better type for reducer actions than { type: string; toolId?: string; startDate?: string }?

With the loose type, every field is optional everywhere, so TypeScript cannot tell that added needs a toolId or that datesChanged needs both dates, and a typo such as 'add' is accepted as a string. With a discriminated union, each case narrows the action to its exact shape, a missing field or unknown type is an error where you dispatch it, and the never check in default flags any action the reducer forgot to handle.

3. A component has const [selectedTool, setSelectedTool] = useState(null); and later reads selectedTool.name. What does TypeScript infer, and what should the line be?

From null alone, TypeScript infers that the state can only ever be null, so setSelectedTool(tool) and selectedTool.name are both errors. Write useState<Tool | null>(null), then check for null before reading properties (selectedTool === null ? … : selectedTool.name); after the check, TypeScript knows the value is a Tool.

Key takeaways

  • TypeScript checks your code in the editor and at build time; types are removed before the code runs and cannot validate runtime data. The live editor here strips types without checking them.
  • Model the data first: object types, unions of string literals for fixed sets such as ToolStatus, optional fields with ?, and Record<Union, Value> for complete lookup tables.
  • Type props with a Props object type; let inference handle state, return types and inline handlers; add type arguments for empty arrays and null initial values.
  • Reducer actions are a discriminated union; a never check in default catches forgotten cases.
  • Treat outside data as unknown and check it with a type guard or schema. Avoid any, and treat as on outside data as a warning sign.

Priority: should know. You don’t need to master TypeScript before Part 2, but you should be able to read and write typed components, state, events and reducers, and to check data at the boundary. For the details, see Using TypeScript in the React documentation and Narrowing in the TypeScript Handbook.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.