React + TypeScript
In this chapter, you learn type-safe frontend development that combines React and TypeScript.
What you learn in this chapter
- Setting up a React project
- Typing function components
- Props and event handler types
- Typing useState, useReducer, and useRef
- Typing useContext
- Custom hooks
Using TypeScript improves the type safety of React components and helps you catch bugs early. It also strengthens IDE completion and boosts development efficiency.
Setting up a React project
# Create a project using Vite (recommended)
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
npm run dev
The generated file structure:
my-react-app/
├── public/
├── src/
│ ├── App.tsx # the main component
│ ├── main.tsx # the entry point
│ └── vite-env.d.ts # Vite's type definitions
├── index.html
├── package.json
├── tsconfig.json # TypeScript config
├── tsconfig.node.json
└── vite.config.ts # Vite config
Typing function components
// src/components/Greeting.tsx
import { FC } from 'react';
// Define the Props type
// Define the props the component receives with an interface
interface GreetingProps {
name: string; // a required prop
age?: number; // an optional prop (add ?)
}
// Approach 1: use FC (FunctionComponent)
// Specify the props type with FC<Props>
const Greeting: FC<GreetingProps> = ({ name, age }) => {
return (
<div>
<h1>Hello, {name}!</h1>
{/* check existence with age !== undefined */}
{age !== undefined && <p>You are {age} years old.</p>}
</div>
);
};
// Approach 2: use a type annotation directly (recommended)
// Specify the type directly on the argument without using FC
function Greeting2({ name, age }: GreetingProps) {
return (
<div>
<h1>Hello, {name}!</h1>
{age !== undefined && <p>You are {age} years old.</p>}
</div>
);
}
export { Greeting, Greeting2 };
The difference between using FC and not using it:
// A component that includes children
interface CardProps {
title: string;
children: React.ReactNode; // define children explicitly
}
// React.ReactNode: a type that can receive JSX elements, strings, numbers, null, arrays, and so on
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
About React 19 (the mainstream version as of 2026)
The code examples in this book assume React 19. The main differences from React 18 / 17 are as follows:
FCis not needed: from React 19 onward, "Approach 2" of annotating the type directly on the argument is recommended.FCis still usable, but the trend is to avoid it in new codechildrentype annotation: the React 18 change whereFCno longer implicitly includeschildrenis the same in React 19. When you receivechildren, declare it explicitly withReact.ReactNoderefas a prop: you can receiverefas a normal prop without wrapping withforwardRef(described later)
React 19's new features and types
Here we organize the APIs added in React 19 and the TypeScript typing for each.
Receiving ref as a prop (no forwardRef needed)
// React 19 and later: you can receive ref as a normal prop
type InputProps = {
label: string
ref?: React.Ref<HTMLInputElement>
}
function LabeledInput({ label, ref }: InputProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
)
}
Before React 18, you needed to wrap with forwardRef<HTMLInputElement, InputProps>(...), but from React 19 this is no longer necessary. Existing forwardRef code still works, so you do not need to rewrite it all at once.
Managing actions with useActionState
A hook for concisely writing async processing such as form submission. You do not have to manage the submitting state (isPending) or errors yourself.
import { useActionState } from 'react'
type FormState = {
message: string
error: string | null
}
async function submitAction(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const name = formData.get('name') as string
if (!name) return { message: '', error: 'Please enter your name' }
await new Promise((r) => setTimeout(r, 500))
return { message: `${name}, your submission is complete`, error: null }
}
function ContactForm() {
// The return value is [current state, action function, submitting flag]
const [state, formAction, isPending] = useActionState(submitAction, {
message: '',
error: null,
})
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state.error && <p>{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
)
}
The type arguments of useActionState<State, Payload> are usually inferred from the argument and return value, so they do not need to be specified.
useFormStatus (reference state from a child component)
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Submitting...' : 'Submit'}</button>
}
It reads the state of the nearest parent <form>. Use it when you componentize a submit button.
useOptimistic (optimistic UI updates)
import { useOptimistic } from 'react'
function LikeButton({ count: serverCount }: { count: number }) {
const [optimisticCount, addOptimistic] = useOptimistic(
serverCount,
(current: number) => current + 1,
)
async function handleLike() {
addOptimistic(null) // update the UI first
await fetch('/api/like', { method: 'POST' })
}
return (
<form action={handleLike}>
<button type="submit">Like ({optimisticCount})</button>
</form>
)
}
It updates the UI ahead of the server response and replaces it with the actual value when it is confirmed.
Document Metadata (auto-hoisting of <title> / <meta>)
In React 19, <title> / <meta> / <link> written inside a component are automatically hoisted into <head>. The role that libraries like react-helmet used to handle is now built into React itself.
function AboutPage() {
return (
<>
<title>About Us | MyApp</title>
<meta name="description" content="The company information page for MyApp" />
<h1>About Us</h1>
</>
)
}
Event handler types
import { useState, ChangeEvent, FormEvent, MouseEvent } from 'react';
function LoginForm() {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
// An input change event
// ChangeEvent<HTMLInputElement>: the type of an input element's change event
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
// e.target.value: get the entered value
setEmail(e.target.value);
};
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
// A form submit event
// FormEvent<HTMLFormElement>: the type of a form element's submit event
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
// Prevent the default submit behavior
e.preventDefault();
console.log('Login:', { email, password });
};
// A click event
// MouseEvent<HTMLButtonElement>: the type of a button element's click event
const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => {
// e.clientX, e.clientY: the coordinates of the click position
console.log('Button clicked at:', e.clientX, e.clientY);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={handlePasswordChange}
placeholder="Password"
/>
<button type="submit" onClick={handleButtonClick}>
Login
</button>
</form>
);
}
Commonly used event types:
| Event | Type |
|---|---|
| input change | ChangeEvent<HTMLInputElement> |
| textarea change | ChangeEvent<HTMLTextAreaElement> |
| select change | ChangeEvent<HTMLSelectElement> |
| form submit | FormEvent<HTMLFormElement> |
| click | MouseEvent<HTMLButtonElement> |
| keyboard | KeyboardEvent<HTMLInputElement> |
| focus | FocusEvent<HTMLInputElement> |
Typing useState
import { useState } from 'react';
// Define the User interface
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
// ===== Primitive types (type inference works) =====
// The type is automatically inferred from the initial value
const [count, setCount] = useState(0); // number type
const [name, setName] = useState(''); // string type
const [isActive, setIsActive] = useState(false); // boolean type
// ===== When the initial value is null, specify the type explicitly =====
// useState<User | null>(null) represents "User or null"
const [user, setUser] = useState<User | null>(null);
// ===== For arrays =====
// The type cannot be inferred from an empty initial array, so specify it explicitly
const [items, setItems] = useState<string[]>([]);
const [users, setUsers] = useState<User[]>([]);
// ===== For objects =====
const [formData, setFormData] = useState<{ name: string; age: number }>({
name: '',
age: 0,
});
// Logic to fetch user data
const fetchUser = async () => {
// Fetch data from the API
const data: User = await fetch('/api/user').then(r => r.json());
// Set it on the state that expects the User type
setUser(data);
};
// Add to the array
const addItem = (item: string) => {
// Use prev to update based on the previous state
setItems(prev => [...prev, item]);
};
return (
<div>
{/* Display only when user is not null */}
{user ? (
<p>Welcome, {user.name}!</p>
) : (
<button onClick={fetchUser}>Load User</button>
)}
</div>
);
}
Typing useReducer
useReducer is well suited to complex state management.
import { useReducer } from 'react';
// Define the State type
interface CounterState {
count: number;
step: number;
}
// The Action type (defined as a union type)
// Define a type and payload for each action
type CounterAction =
| { type: 'INCREMENT' } // no payload
| { type: 'DECREMENT' } // no payload
| { type: 'SET_STEP'; payload: number } // with payload
| { type: 'RESET' }; // no payload
// The initial state
const initialState: CounterState = {
count: 0,
step: 1,
};
// The reducer function
// state: the current state, action: the action to perform
// return value: the new state
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT':
// Copy the existing state with spread syntax and update only count
return { ...state, count: state.count + state.step };
case 'DECREMENT':
return { ...state, count: state.count - state.step };
case 'SET_STEP':
// action.payload: the SET_STEP action has a numeric payload
return { ...state, step: action.payload };
case 'RESET':
return initialState;
default: {
// exhaustive check: verify that all cases are covered
// - if action is narrowed to the never type, everything is handled
// - if it is not narrowed, there is a missing case in CounterAction (compile error)
// For details, see the exhaustiveness check section in Chapter 11, "Type narrowing"
const _exhaustiveCheck: never = action;
return _exhaustiveCheck; // this is never reached
}
}
}
function Counter() {
// useReducer: pass the reducer and initial state, and get [state, dispatch]
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<p>Step: {state.step}</p>
{/* Perform an action with dispatch */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'SET_STEP', payload: 5 })}>
Set Step to 5
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
Typing useRef
import { useRef, useEffect } from 'react';
function TextInput() {
// A reference to a DOM element
// useRef<HTMLInputElement>(null): a reference to an input element
// The initial value is null, and the DOM element is set after mounting
const inputRef = useRef<HTMLInputElement>(null);
// Holding a mutable value (does not cause a re-render)
// useRef<number>(0): holds a number
const renderCount = useRef<number>(0);
// Holding a timer ID
// ReturnType<typeof setTimeout>: the return type of setTimeout
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Update the ref's value (no re-render occurs)
renderCount.current += 1;
console.log(`Rendered ${renderCount.current} times`);
});
const focusInput = () => {
// A null check is needed (access safely with ?.)
inputRef.current?.focus();
};
const startTimer = () => {
timerRef.current = setTimeout(() => {
console.log('Timer fired!');
}, 1000);
};
const cancelTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
return (
<div>
{/* Bind to the DOM element with the ref property */}
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
<button onClick={startTimer}>Start Timer</button>
<button onClick={cancelTimer}>Cancel Timer</button>
</div>
);
}
The main uses of useRef:
| Use | Type example |
|---|---|
| A reference to a DOM element | useRef<HTMLInputElement>(null) |
| Holding a value (no re-render) | useRef<number>(0) |
| A timer ID | useRef<ReturnType<typeof setTimeout> | null>(null) |
| Holding a previous value | useRef<T>(initialValue) |
Typing useContext
Use Context to share data between components.
import { createContext, useContext, useState, ReactNode } from 'react';
// Define the Context type
interface ThemeContextType {
theme: 'light' | 'dark'; // the kind of theme
toggleTheme: () => void; // a function to toggle the theme
}
// Create a Context with no default value
// Allowing undefined makes it possible to detect use outside a Provider
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Access type-safely with a custom hook
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
// Error if used outside a Provider
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// The Provider component's props
interface ThemeProviderProps {
children: ReactNode; // receive child components
}
// The Provider component
function ThemeProvider({ children }: ThemeProviderProps) {
// Manage the theme state internally
const [theme, setTheme] = useState<'light' | 'dark'>('light');
// A function to toggle the theme
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};
return (
// Provide the context value with the value prop
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Usage
function ThemedButton() {
// Get the Context value with the useTheme hook
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff',
}}
>
Current theme: {theme}
</button>
);
}
// The App component
function App() {
return (
// Wrap with ThemeProvider
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
}
Custom hooks
Extract reusable logic as a custom hook.
A custom hook for API fetching
import { useState, useEffect, useCallback } from 'react';
// The custom hook's return type
interface UseFetchResult<T> {
data: T | null; // the fetched data
loading: boolean; // whether it is loading
error: Error | null; // error information
refetch: () => void; // a refetch function
}
// Use the generic type T to flexibly specify the data type
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
// The data-fetching function
// Memoize it with useCallback (reuse the same function as long as url does not change)
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (e) {
// Handle the error type appropriately
setError(e instanceof Error ? e : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
// Fetch data on mount and when url changes
useEffect(() => {
fetchData();
}, [fetchData]);
// Return the state and the refetch function
return { data, loading, error, refetch: fetchData };
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
// Fetch a User array with useFetch<User[]>
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!users) return <div>No data</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
}
A custom hook for local storage
import { useState } from 'react';
// Use the generic type T to specify the type of the stored value
// Return value: a tuple of [value, update function]
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Get the initial value (lazy initialization)
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// Get the value from localStorage
const item = window.localStorage.getItem(key);
// If it exists, parse it; otherwise, use the initial value
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// Update the value
const setValue = (value: T | ((prev: T) => T)) => {
try {
// If a function is passed, run it with the current value as the argument
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// Also save it to localStorage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
function Settings() {
// Save settings with useLocalStorage
const [settings, setSettings] = useLocalStorage('settings', {
theme: 'light',
fontSize: 14,
});
return (
<div>
<p>Current theme: {settings.theme}</p>
<button onClick={() => setSettings(prev => ({
...prev,
theme: prev.theme === 'light' ? 'dark' : 'light'
}))}>
Toggle Theme
</button>
</div>
);
}
Try it: build a type-safe form component ★★★
Create a registration form component that meets the following requirements.
Requirements:
- A form that can input a name, email address, and age
- Print the input data to the console on submit
- Add proper TypeScript types to all inputs
- Validation (name required, email format, age 0-150)
Hint
- Define the form data type with an
interface - Use
ChangeEvent<HTMLInputElement>for each input's event handler - Manage the form data and errors with
useState - Validate with a validation function before submitting
Answer and explanation
import { useState, FormEvent, ChangeEvent } from 'react';
// Define the form data type
interface FormData {
name: string;
email: string;
age: string; // input is a string, so convert it on submit
}
// Define the error message type
interface FormErrors {
name?: string;
email?: string;
age?: string;
}
function RegistrationForm() {
// The form data state
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
age: '',
});
// The error message state
const [errors, setErrors] = useState<FormErrors>({});
// The submitted flag
const [isSubmitted, setIsSubmitted] = useState(false);
// The input change handler (shared across all fields)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Update dynamically using name as the key
setFormData(prev => ({
...prev,
[name]: value,
}));
// Clear the error on input
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({
...prev,
[name]: undefined,
}));
}
};
// The validation function
const validate = (): boolean => {
const newErrors: FormErrors = {};
// Name: required
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
// Email: format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Please enter a valid email address';
}
// Age: in the range 0-150
if (!formData.age.trim()) {
newErrors.age = 'Age is required';
} else {
const ageNum = parseInt(formData.age, 10);
if (isNaN(ageNum) || ageNum < 0 || ageNum > 150) {
newErrors.age = 'Please enter an age between 0 and 150';
}
}
setErrors(newErrors);
// true if there are no errors
return Object.keys(newErrors).length === 0;
};
// The form submit handler
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (validate()) {
// Validation succeeded
console.log('Registration data:', {
name: formData.name,
email: formData.email,
age: parseInt(formData.age, 10),
});
setIsSubmitted(true);
}
};
// If submitted
if (isSubmitted) {
return (
<div>
<h2>Registration complete!</h2>
<p>Welcome, {formData.name}!</p>
<button onClick={() => {
setFormData({ name: '', email: '', age: '' });
setIsSubmitted(false);
}}>
Register again
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<h2>User Registration</h2>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder="Yamada Taro"
/>
{errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="example@email.com"
/>
{errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input
id="age"
name="age"
type="number"
value={formData.age}
onChange={handleChange}
placeholder="25"
min="0"
max="150"
/>
{errors.age && <span style={{ color: 'red' }}>{errors.age}</span>}
</div>
<button type="submit">Register</button>
</form>
);
}
export default RegistrationForm;
Explanation:
- Type definitions: the
FormDataandFormErrorsinterfaces define the form's types - A shared handler:
handleChangeupdates fields dynamically using thenameattribute - Validation: the
validatefunction checks each field and returns an error object - Type-safe updates:
name as keyof FormErrorsaccesses the object's key type-safely - Conditional rendering: the display switches based on whether it has been submitted
Improvement points:
- In a real production app, using libraries like
react-hook-formorzodmakes validation more efficient - Managing the error message styling with CSS classes makes it more maintainable
Topics to learn further
After learning the basics in this chapter, we recommend additionally learning the following topics.
State management libraries
For large applications, it is common to introduce a dedicated state management library. As of 2026, Zustand v5 is popular for being lightweight and simple.
// Zustand v5 (lightweight and type-safe)
// In TypeScript, the curried create<T>()(...) form is recommended
import { create } from 'zustand';
interface UserStore {
user: User | null;
setUser: (user: User) => void;
logout: () => void;
}
const useUserStore = create<UserStore>()((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// Subscribe to only the values you need with a selector (minimize re-renders)
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);
Redux Toolkit is still a strong option. For large, complex state management or when you need the Redux DevTools debugging features, Redux Toolkit is common; when you want a simple, lightweight API, Zustand is the common choice.
Form libraries
For complex forms, the combination of react-hook-form + zod is popular.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Must be at least 8 characters'),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</form>
);
}
Data fetching libraries
For communicating with APIs, TanStack Query v5 (formerly React Query) is the de facto standard as of 2026. It is the most mature option, with cache management, background updates, and Suspense integration.
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
// In v5, prefer isPending over isLoading (it covers both the initial load and background refetch)
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
If you combine it with Suspense, using useSuspenseQuery lets you delegate the loading handling to the parent <Suspense>, and inside the component you can write code assuming that data always exists.
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{data.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId={1} />
</Suspense>
);
}
Summary
What you learned in this chapter:
- Project setup: creating a React + TypeScript project with Vite
- Function component types: defining Props types, handling children
- Event handler types: ChangeEvent, FormEvent, MouseEvent, and so on
- useState types: primitive types, arrays, objects, nullable
- useReducer types: State type, Action type (union type), exhaustive check
- useRef types: DOM references, holding mutable values
- useContext types: creating a Context, custom hooks, Provider
- Custom hooks: reusable logic like useFetch and useLocalStorage
In the next chapter, you will learn about Express + TypeScript and best practices.
Common pitfalls for beginners
Common mistakes
❌ Getting the event handler type wrong
// ❌ Using the generic Event
const handleChange = (e: Event) => {
console.log(e.target.value); // Error: the type of target is unknown
};
// ✅ Use a type that matches the element
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value); // OK: the value property is accessible
};
Cause: With the generic Event type, you cannot access properties of target (such as value).
Solution: Use a type that matches the element, such as ChangeEvent<HTMLInputElement>.
❌ Not considering null in the initial value of useState
// ❌ Ignoring the possibility of null
const [user, setUser] = useState<User>(); // User | undefined
function handleClick() {
console.log(user.name); // Error: user may be undefined
}
// ✅ Allow null explicitly and check it
const [user, setUser] = useState<User | null>(null);
function handleClick() {
if (user) {
console.log(user.name); // OK
}
}
Cause: When the initial value is null/undefined, you must include that possibility in the type.
Solution: Declare useState<User | null>(null) and do a null check when using it.
❌ Forgetting that useRef's .current can be null
// ❌ Forgetting the null check
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current.focus(); // Error: current may be null
}
// ✅ Do a null check
function focusInput() {
inputRef.current?.focus(); // OK
// Or
if (inputRef.current) {
inputRef.current.focus();
}
}
Cause: When referencing a DOM element with useRef, the initial value is null, and it is set after mounting.
Solution: Do a null check with ?. (Optional Chaining) or an if statement.
❌ Receiving props without a type definition
// ❌ The props type is unknown
function UserCard(props) { // implicit any
return <div>{props.name}</div>;
}
// ✅ Define the type explicitly
interface UserCardProps {
name: string;
age?: number;
}
function UserCard({ name, age }: UserCardProps) {
return (
<div>
{name}
{age && <span> ({age})</span>}
</div>
);
}
Cause: Without a props type definition, it becomes implicit any and loses type safety.
Solution: Define the props type with an interface and receive it with destructuring.
❌ Generics cannot be interpreted in JSX
// ❌ In a .tsx file, <T> is interpreted as a JSX tag
const identity = <T>(value: T): T => value; // Error
// ✅ Approach 1: add a comma
const identity1 = <T,>(value: T): T => value;
// ✅ Approach 2: add extends
const identity2 = <T extends unknown>(value: T): T => value;
// ✅ Approach 3: use a function declaration
function identity3<T>(value: T): T {
return value;
}
Cause: In a .tsx file, <T> is interpreted as a JSX tag.
Solution: Distinguish it from JSX with <T,> or <T extends unknown>.