TypeScript in React: Practical Typing Patterns That Actually Help

TypeScript in React: Practical Typing Patterns That Actually Help

I avoided TypeScript in my React projects longer than I should have. The error messages looked cryptic, the setup seemed heavy, and I already had PropTypes. Then a junior dev introduced a subtle prop-shape bug that took three hours to track down — a bug TypeScript would have caught at save time. I've typed my React ever since.

This post skips the philosophy and goes straight to the five patterns I reach for every day.

1. Typing component props with an interface

The most common thing you'll type is a component's props. Define an interface just above the component and pass it as the generic argument to

1React.FC
, or just annotate the destructured parameter directly — the second approach is less noisy:

1interface ButtonProps {
2  label: string;
3  onClick: () => void;
4  disabled?: boolean; // optional — note the '?'
5  variant?: 'primary' | 'secondary'; // union type keeps valid values explicit
6}
7
8export function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
9  return (
10    <button
11      className={`btn btn-${variant}`}
12      onClick={onClick}
13      disabled={disabled}
14    >
15      {label}
16    </button>
17  );
18}
19

The union type

1'primary' | 'secondary'
is more useful than
1string
. Passing
1variant="danger"
now gives a compile-time error instead of a silent style mismatch at runtime.

2. useState with generics

1useState
infers its type from the initial value, which works fine for primitives. Where it breaks down is when the initial value is
1null
or an empty array, because the inferred type becomes
1null
or
1never[]
and TypeScript won't let you assign anything real to it later.

Fix this by passing the type explicitly:

1// Bad — TypeScript infers the type as `null` only, so setUser won't accept a User object
2const [user, setUser] = useState(null);
3
4// Good — TypeScript knows it's User | null from the start
5interface User {
6  id: number;
7  name: string;
8  email: string;
9}
10
11const [user, setUser] = useState<User | null>(null);
12const [items, setItems] = useState<string[]>([]);
13

With the typed version,

1setUser({ id: 1, name: 'Alice', email: '[email protected]' })
compiles cleanly, and
1setUser({ id: 1, username: 'alice' })
(wrong shape) errors immediately.

3. Event handler types

Event types in React are namespaced under

1React
. The ones I use constantly:

1function SearchBox() {
2  const [query, setQuery] = useState('');
3
4  // React.ChangeEvent<HTMLInputElement> is the type of the onChange event
5  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
6    setQuery(e.target.value);
7  }
8
9  // React.FormEvent<HTMLFormElement> for form submit
10  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
11    e.preventDefault();
12    console.log('Searching for:', query);
13  }
14
15  return (
16    <form onSubmit={handleSubmit}>
17      <input value={query} onChange={handleChange} />
18      <button type="submit">Search</button>
19    </form>
20  );
21}
22

The pattern is always

1React.<EventType><HTMLElement>
. A few to memorise:

| Event | HTML element | Type | |---|---|---| |

1onChange
|
1<input>
|
1React.ChangeEvent<HTMLInputElement>
| |
1onChange
|
1<select>
|
1React.ChangeEvent<HTMLSelectElement>
| |
1onSubmit
|
1<form>
|
1React.FormEvent<HTMLFormElement>
| |
1onClick
| anything |
1React.MouseEvent<HTMLButtonElement>
| |
1onKeyDown
| anything |
1React.KeyboardEvent<HTMLInputElement>
|

4. Typing API responses

A common mistake is leaving

1fetch
response data as
1any
. That defeats the whole point. Define an interface that mirrors the API shape and cast once at the boundary:

1interface Post {
2  id: number;
3  title: string;
4  body: string;
5  userId: number;
6}
7
8async function fetchPost(id: number): Promise<Post> {
9  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
10  if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
11  return res.json() as Promise<Post>;
12}
13
14// In a component:
15function PostDetail({ id }: { id: number }) {
16  const [post, setPost] = useState<Post | null>(null);
17
18  useEffect(() => {
19    fetchPost(id).then(setPost);
20  }, [id]);
21
22  if (!post) return <p>Loading...</p>;
23
24  return <h2>{post.title}</h2>; // TypeScript knows post.title is a string
25}
26

The

1as Promise<Post>
cast on
1res.json()
is the one spot where you're trusting the server. If the shape changes, you only have one place to update.

5. Typing custom hooks

Custom hooks are where TypeScript earns its keep most clearly, because the return type becomes part of the hook's public API.

1interface UseFetchResult<T> {
2  data: T | null;
3  loading: boolean;
4  error: string | null;
5}
6
7function useFetch<T>(url: string): UseFetchResult<T> {
8  const [data, setData] = useState<T | null>(null);
9  const [loading, setLoading] = useState(true);
10  const [error, setError] = useState<string | null>(null);
11
12  useEffect(() => {
13    setLoading(true);
14    fetch(url)
15      .then((res) => {
16        if (!res.ok) throw new Error(`HTTP ${res.status}`);
17        return res.json() as Promise<T>;
18      })
19      .then((json) => {
20        setData(json);
21        setLoading(false);
22      })
23      .catch((err: Error) => {
24        setError(err.message);
25        setLoading(false);
26      });
27  }, [url]);
28
29  return { data, loading, error };
30}
31
32// Usage — TypeScript knows posts is Post[] | null
33const { data: posts, loading } = useFetch<Post[]>('/api/posts');
34

The generic

1<T>
makes the hook reusable: call it once for posts, once for users, once for anything — and each callsite gets fully typed data back.

Where to go from here

These five patterns cover the majority of TypeScript I write in day-to-day React work. Once they feel automatic, the natural next step is

1React.ReactNode
and
1React.PropsWithChildren
for components that render children, and
1useReducer
with a discriminated union for complex state. But honestly, mastering props, state, events, API data, and custom hooks will make you more productive with TypeScript in React than reading any amount of theory.

Start by converting your next new component with typed props. The immediate feedback loop — catching a wrong prop type before you ever run the code — is what converts sceptics.