Chapter 19 of 24 · TypeScript for Beginners — Complete Free Tutorial

TypeScript with React

In the TypeScript for Beginners series — a 24-chapter path from your first annotation to typed code you can ship — you have spent eighteen chapters on the type system on its own. This nineteenth chapter plugs it into React, the UI library TypeScript pairs with best. You will learn why JSX lives in .tsx files, how to type props, state, events, refs and context, and how a typed fetch effect reports loading, failure and cancellation without lying to the compiler.

JSX belongs in .tsx files

TypeScript will not parse JSX unless two things are true: the file ends in .tsx, and the jsx compiler option says how that JSX should be lowered. Modern React uses the automatic runtime — "jsx": "react-jsx", the option Vite writes for you — so components no longer begin with import React from 'react'. Two extension rules are worth memorising: JSX typed into a .ts file stops the parser with error TS1005: '>' expected., and JSX compiled with no jsx option fails with error TS17004: Cannot use JSX unless the '--jsx' flag is provided. From here, a component is a typed function whose first parameter is the props object — every idea in this chapter is the React for Beginners mental model with its types written down, and chapter 20 opens the configuration file itself.

Typing props with an interface

Props are an object, so describe them with an interface and annotate the destructured parameter. Required members, optional members marked ? and literal unions behave exactly as they did in chapter 5, and the parent's JSX becomes a checked call site:

interface CourseCardProps {
  title: string;
  minutes: number;
  level: 'beginner' | 'intermediate' | 'advanced';
  completed?: boolean;
}
export default function CourseCard({ title, minutes, level, completed = false }: CourseCardProps) {
  return (
    <article className={completed ? 'card card--done' : 'card'}>
      <h3>{title}</h3>
      <p>{minutes} minutes - {level}</p>
    </article>
  );
}

Expected output: a card whose class becomes card card--done once completed is true. A wrong type at the call site fails where it is written — <CourseCard title="Types" minutes="45" /> reports error TS2322: Type 'string' is not assignable to type 'number'. on minutes, before the browser ever renders a card whose duration is the string “45”. Three habits are worth forming immediately: annotate the destructured parameter, because a standalone function gets no contextual type from the JSX that will render it (function CourseCard({ title }) reports error TS7006: Parameter 'title' implicitly has an 'any' type.); declare children yourself as React.ReactNode, since the React 18 type definitions removed it from React.FC; and reserve interface for props other files consume — the preference chapter 24 formalises.

Typing state with useState<T>

useState infers its type from the initial value; you override that guess with an explicit type argument whenever the initial value is too narrow. The counter from State with useState typed up:

const [count, setCount] = useState(0);            // number
const [query, setQuery] = useState('');           // string
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'ready'>('idle');
const [tags, setTags] = useState<string[]>();      // string[] | undefined

Expected output: setCount('two') reports error TS2322: Type 'string' is not assignable to type 'number' and setStatus('loadin') never compiles. Three details pay off quickly: with no initial argument the value is S | undefined rather than a silent lie; functional updates infer their parameter, so setCount((prev) => prev + 1) types prev as number with no annotation; and a lazy initialiser such as useState(() => loadTheme()) takes its type from the callback's return value.

Typing events, refs and context

React's event props are generic: you parameterise the event with the element that fired it. Inline arrow functions in JSX are inferred from the prop, so only named handlers need annotations — either the React. namespace or a type-only import works:

function handleSearch(event: React.ChangeEvent<HTMLInputElement>) {
  setQuery(event.target.value);          // string, guaranteed
}
  • React.ChangeEvent<HTMLInputElement> for onChange — event.target.value is always a string.
  • React.FormEvent<HTMLFormElement> for onSubmit — event.currentTarget is the real form element.
  • React.MouseEvent<HTMLButtonElement> for onClick — event.clientX and event.shiftKey are typed.
  • React.KeyboardEvent<HTMLInputElement> for onKeyDown — event.key is a string, not string | undefined.

Refs and context follow the same rule: name what the value will be, then respect what the hook returns. useRef never promises a value — .current starts as null, so reach for optional chaining, and a timer id is simply a useRef<number | null>(null):

const emailRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | null>(null);

function focusEmail() {
  emailRef.current?.focus();     // current is null until the DOM mounts
}
type AuthContextValue = { user: User | null; signIn: (email: string) => Promise<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;
}

Expected output: React 18's types call the first ref an RefObject<HTMLInputElement> and React 19's a RefObject<HTMLInputElement | null>, but both make emailRef.current.focus() a compile error under strictNullChecks. And calling useAuth() outside its provider throws useAuth must be used inside AuthProvider at runtime, while every consumer inside the tree reads a fully typed AuthContextValue — the typed version of the provider built in Managing Global State with Context.

A typed fetch effect with cancellation

Data fetching is where the types earn their keep. This effect combines the async discipline of chapter 18 with the effect lifecycle from Effects with useEffect: type the payload, treat every caught value as unknown, and abort in the cleanup so a late response never lands in state.

type Course = { id: number; title: string; minutes: number };
export default function CourseList({ track }: { track: string }) {
  const [courses, setCourses] = useState<Course[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    async function load() {
      try {
        const res = await fetch(`/api/courses?track=${track}`, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data: Course[] = await res.json();
        setCourses(data);
      } catch (err: unknown) {
        if (controller.signal.aborted) return;
        setError(err instanceof Error ? err.message : 'Something went wrong');
      }
    }
    void load();
    return () => controller.abort();
  }, [track]);
  if (!courses) return error ? <p className="error">{error}</p> : <p role="status">Loading...</p>;
  return <ul>{courses.map((c) => <li key={c.id}>{c.title}</li>)}</ul>;
}

Expected output: a “Loading...” paragraph while the request runs, then one list item per course, or the error paragraph when the server answers HTTP 404. Switching track mid-flight aborts the first request, and its rejected promise is swallowed by the controller.signal.aborted check instead of painting an error over fresh data. Two lines deserve a comment: res.json() is typed Promise<any>, so const data: Course[] is the real check, and catch (err: unknown) is legal precisely because strict turns on useUnknownInCatchVariables — the flag chapter 20 documents.

A complete typed form

Bring the pieces together: a controlled form where every state value, every event and the callback that leaves the component are typed — the TypeScript sibling of Forms and Controlled Components. The inline onChange arrows get their event type from the JSX prop, so only handlers you name need an annotation:

import { useState } from 'react';

interface SignupValues { email: string; plan: 'free' | 'pro' }
export default function SignupForm({ onSubmit }: { onSubmit: (v: SignupValues) => void }) {
  const [email, setEmail] = useState('');
  const [plan, setPlan] = useState<'free' | 'pro'>('free');
  const [error, setError] = useState<string | null>(null);
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!email.includes('@')) return setError('Enter a valid email address');
    setError(null);
    onSubmit({ email, plan });
  }
  return (
    <form onSubmit={handleSubmit} noValidate>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <select value={plan} onChange={(e) => setPlan(e.target.value === 'pro' ? 'pro' : 'free')}>
        <option value="free">Free</option>
        <option value="pro">Pro</option>
      </select>
      {error && <p role="alert">{error}</p>}
      <button type="submit">Create account</button>
    </form>
  );
}

Expected output: submitting ada renders Enter a valid email address inside an alert paragraph; correcting it to ada@example.com clears the message and calls the parent's callback, which logs signup: ada@example.com / free. Every wrong move upstream is a compile error instead of a runtime surprise: setPlan('basic') fails, passing { count: 1 } to onSubmit fails, and a typo such as event.target.vale inside a handler is caught before the browser runs it. Note that the <select> arrow narrows the raw string with a comparison rather than reaching for as 'free' | 'pro' — assertions are covered in chapter 24, and this is the shape you should prefer.

Common mistakes

  • Keeping the .js extension after adding JSX — the file either parses as broken JavaScript or trips error TS17004; rename it .tsx.
  • Letting JSX infer props from usage — an unannotated destructured parameter is any and reports TS7006; annotate with the props interface.
  • Typing state as any to silence null — useState<any>(null) hides every downstream bug; write useState<User | null>(null) and narrow instead.
  • Casting e.target to reach .value — parameterise the event with the element (ChangeEvent<HTMLSelectElement>) and the property is already typed.
  • Forgetting cleanup in a typed effect — types cannot catch a missing return () => controller.abort(); stale responses still overwrite fresh state.

Practice checklist

  1. Convert one component from the React series to .tsx and give its props an interface with one optional member and one literal union.
  2. Build a counter and a text field side by side; annotate the named handlers with ChangeEvent and confirm event.target.value needs no cast.
  3. Write a useRef<HTMLInputElement>(null) focus helper and read the compiler error you get without optional chaining.
  4. Type a fetch effect with an AbortController, then break it on purpose by removing the cleanup and watching the race in the browser.
  5. Recreate the signup form and call onSubmit with a wrong field type to see the error land at the call site.

What's next?

Your components are typed, but they are only as strict as the configuration behind them. Next you open that configuration and read every switch: Strict Mode and tsconfig Deep Dive.

Quiz: TypeScript with React

5 questions · 70% to pass

Frequently Asked Questions

TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.

You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.

No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.

If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.

Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.

strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.

any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.

Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.

Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.

Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.