React with TypeScript: A Beginner Guide to Typing Props, State and Events

React.js Full Course: Beginner to Advanced · Advanced React · 11 min read · Updated

Types catch mistakes early

  1. You describe the shape of your data with a type, such as User.
  2. Components use the type for their props and state.
  3. If you pass a wrong field or forget a prop, the editor shows a red line right away.
  4. When you build the app, the types are removed and plain JavaScript is left.
  5. Remember: a type cannot check what the server really sends at run time. Avoid any.

TypeScript is JavaScript with types. A type says what kind of value something is: a number, a piece of text, a list of users. TypeScript checks this while you write, and underlines mistakes in your editor before you run the app. Many React jobs ask for TypeScript, because it catches many bugs early and makes big projects easier to understand. This lesson shows the parts you need for React, with plain language.

Why use TypeScript?

  • It finds typos and wrong values as you type, such as user.nmae or passing a string where a number is needed.
  • It gives better auto-complete in your editor, because it knows the shape of your data.
  • It works as living documentation: you can see what props a component needs without opening it.
  • It makes changing code safer, because the editor shows every place that must change.

The price is a little extra writing and a short time to learn. TypeScript is removed when your app is built, so the browser still runs plain JavaScript. It does not make code run faster or slower.

Start a TypeScript project

npm create vite@latest my-ts-app -- --template react-ts
cd my-ts-app
npm install
npm run dev

Files now end in .ts (plain code) and .tsx (code with JSX). Use .tsx for components.

The basic types

const name: string = 'Asha';
const age: number = 24;
const isAdmin: boolean = false;
const tags: string[] = ['react', 'css'];       // a list of text
const scores: Array<number> = [1, 2, 3];       // the same idea
const pair: [string, number] = ['age', 24];    // a tuple: exact shape

let id: string | number = 5;                   // a union: one or the other
id = 'abc';                                    // fine

function double(n: number): number {
  return n * 2;
}

TypeScript can often guess a type, so you do not always write it. const age = 24 is already known to be a number. Write types where TypeScript cannot guess, such as function parameters.

Describing objects: type and interface

type User = {
  id: number;
  name: string;
  email: string;
  phone?: string;              // the ? means optional
  role: 'admin' | 'editor' | 'viewer';   // only these words are allowed
};

const user: User = { id: 1, name: 'Asha', email: 'a@b.com', role: 'admin' };
// user.role = 'boss';   // error: not one of the allowed words

interface is almost the same and you will see both. A simple rule: use type or interface consistently in your project. The union of allowed words, like 'admin' | 'editor', is very useful for statuses and options.

Typing props

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

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

// <Button label="Save" onClick={save} />            fine
// <Button label="Save" />                           error: onClick is missing
// <Button label={5} onClick={save} />               error: label must be text

Now the editor tells you exactly which props a component needs and what type each should be, with no need to open the file.

For components that take children, use ReactNode:

import type { ReactNode } from 'react';

type CardProps = {
  title: string;
  children: ReactNode;
};

function Card({ title, children }: CardProps) {
  return <section><h2>{title}</h2>{children}</section>;
}

Typing state

useState often guesses the type from the first value. When the first value does not show the full type, tell it with angle brackets (a generic):

const [count, setCount] = useState(0);                  // number, guessed
const [name, setName] = useState('');                   // string, guessed

const [user, setUser] = useState<User | null>(null);    // a User, or nothing yet
const [items, setItems] = useState<string[]>([]);       // an empty list of text

useState<User | null>(null) says: this may hold a user or null. Now TypeScript makes you check for null before using it, which prevents the very common error “cannot read property of null”:

if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;     // safe: TypeScript knows user is a User here

Typing events

import type { ChangeEvent, FormEvent } from 'react';

function Form() {
  const [email, setEmail] = useState('');

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

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    console.log(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={handleChange} />
    </form>
  );
}

You do not have to remember these names. If you write the handler inline, TypeScript knows the type already: onChange={(e) => setEmail(e.target.value)}. You only need a name when you write the handler as a separate function. Your editor will tell you the right one when you hover over the prop.

Typing refs

import { useRef } from 'react';

const inputRef = useRef<HTMLInputElement>(null);

function focusInput() {
  inputRef.current?.focus();     // ?. because it could be null
}

<input ref={inputRef} />

Typing API data

Describe what you expect from the server, then use that type when you fetch:

type Post = { id: number; title: string; body: string };

async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
  if (!response.ok) throw new Error('Request failed');
  return response.json();
}

function Posts() {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    fetchPosts().then(setPosts);
  }, []);

  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Typing context and custom hooks

type AuthContextValue = {
  user: User | null;
  login: (name: string) => void;
  logout: () => void;
};

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

export function useAuth(): AuthContextValue {
  const value = useContext(AuthContext);
  if (!value) throw new Error('useAuth must be used inside an AuthProvider');
  return value;
}

// a generic custom hook
export function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const saved = localStorage.getItem(key);
    return saved ? (JSON.parse(saved) as T) : initial;
  });
  return [value, setValue] as const;
}

The T in useLocalStorage<T> is a generic: a placeholder for any type. useLocalStorage('count', 0) makes T a number, and useLocalStorage('user', { name: '' }) makes it an object. One hook, many types, all checked.

Useful tools to know

  • `Partial<User>` makes all fields optional. Useful for updates.
  • `Pick<User, 'id' | 'name'>` keeps only some fields, and `Omit<User, 'email'>` removes some.
  • `Record<string, number>` is an object with text keys and number values.
  • `as const` makes a value read-only and exact.
  • `unknown` is a safe version of any. It forces you to check the type before using it.

Avoid any

The type any turns off checking for that value. It is tempting when a type is hard, but each any removes the safety you came for. Use unknown and narrow it, or write a simple type. Turn on strict mode in tsconfig.json (Vite does it for you), so TypeScript is as helpful as possible.

Reading TypeScript errors

Errors can look long. Read the last line first, because it states the real difference, and read where (file and line). Hover the red line in your editor for a clearer explanation. Most errors in the first weeks are: a value that might be null, a missing prop, a wrong type in a prop, or a typo in a field name. All are good catches.

Common mistakes

  • Using any to make errors go away.
  • Typing useState([]) with no type, which makes the list never[]. Write useState<string[]>([]).
  • Forgetting that data from the network is not checked at runtime.
  • Casting with as to silence an error, hiding a real problem.
  • Not enabling strict mode.

A tiny TypeScript story

Here is why people like TypeScript, in a small story. A teammate renames the field name to fullName in the User type. In plain JavaScript, nothing tells you which of your thirty components used user.name. Some of them silently show undefined on the page, and you only find out when a visitor complains. In TypeScript, the moment the type changes, the editor underlines every place that still says user.name. You fix them in a few minutes, and nothing reaches your visitors.

That is the main benefit: mistakes show up while you write, not after you publish. It is a safety net for changing code, and the bigger the project, the more it helps. For a tiny practice app you may not need it, and that is fine. Many people learn React first and add TypeScript in the next project.

How to move from JavaScript step by step

  • Start a new project with the react-ts template, and write only simple types at first: props, and state with a generic.
  • Let TypeScript guess types where it can, and write types for function parameters and data from the network.
  • Read each error calmly. They are hints about real mistakes.
  • Add more advanced types, such as generics and utility types, only when you need them.

Your task

  1. Create a Vite React TypeScript project and make a Button component with typed props, including a variant union.
  2. Create a User type, and a UserCard component that takes a user prop. Try passing a wrong field and read the error.
  3. Fetch posts from the sample API and type the result with a Post type.
  4. Make a generic useLocalStorage<T> hook and use it with a string and with an object.

How to check it worked

  • The editor shows red lines when you pass a wrong prop, and the error message makes sense.
  • Auto-complete shows the fields of your User type.
  • npm run build runs with no TypeScript errors.

Summary

  • TypeScript adds types to JavaScript and finds mistakes in your editor before you run the app.
  • Type props with type or interface, state with useState<Type>(), and events with ChangeEvent and similar types.
  • Use unions for fixed options, ? for optional fields, and generics for reusable hooks.
  • Avoid any, check network data at runtime, and keep strict mode on.

Frequently asked questions

Do I need TypeScript to use React?

No. React works with plain JavaScript. TypeScript is popular in teams and jobs because it catches many mistakes early.

Should I learn JavaScript before TypeScript?

Yes. TypeScript is JavaScript plus types, so you need the JavaScript basics first.

What does useState<User | null>(null) mean?

The state can hold a User object or null. TypeScript then makes you check for null before using it.

Does TypeScript run in the browser?

No. It is turned into plain JavaScript when you build the app. The browser never sees the types.