Setting Up TypeScript in a React Project
# New project with Vite (recommended in 2026)
npm create vite@latest my-app -- --template react-ts
# Add TypeScript to existing React project
npm install -D typescript @types/react @types/react-dom
npx tsc --init
Typing Component Props
Interface vs type alias
// Interface — preferred for props (extensible, clearer error messages)
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
children?: React.ReactNode;
}
// Type alias — good for unions and computed types
type ButtonVariant = 'primary' | 'secondary' | 'danger';
Function components
// Modern approach — don't use React.FC (it was deprecated in React 18)
function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) {
return (
<button
className={clsx('btn', `btn-${variant}`)}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
// Arrow function style
const Button = ({ label, onClick }: ButtonProps) => (
<button onClick={onClick}>{label}</button>
);
// Export with type
export type { ButtonProps };
export { Button };
Extending HTML element props
// Extend native HTML props — users can pass any valid button attribute
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
variant?: 'primary' | 'secondary';
isLoading?: boolean;
}
function Button({ label, variant = 'primary', isLoading, ...rest }: ButtonProps) {
return (
<button
className={clsx('btn', `btn-${variant}`, { loading: isLoading })}
disabled={isLoading || rest.disabled}
{...rest} // spreads onClick, type, form, etc.
>
{isLoading ? <Spinner /> : label}
</button>
);
}
Children types
interface CardProps {
children: React.ReactNode; // most permissive: anything React can render
header?: React.ReactElement; // must be a React element (not string/number)
footer?: string | React.ReactElement; // string or element
}
// For components that only accept certain children
interface ListProps {
children: React.ReactElement<ListItemProps> | React.ReactElement<ListItemProps>[];
}
Discriminated Unions for Component States
// ❌ Unclear: which combinations are valid?
interface DataViewProps {
status: 'idle' | 'loading' | 'success' | 'error';
data?: User[];
error?: Error;
}
// ✅ Clear: each state is explicit
type DataViewProps =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: Error };
function DataView(props: DataViewProps) {
if (props.status === 'idle') return <EmptyState />;
if (props.status === 'loading') return <Spinner />;
if (props.status === 'error') return <ErrorMessage error={props.error} />;
// TypeScript knows status is 'success' and data: User[] exists here
return <UserList users={props.data} />;
}
Typing Hooks
useState
// TypeScript infers the type from initial value
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
// Explicit type when initial value is null/undefined
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
// Complex state
interface FormState {
name: string;
email: string;
errors: Record<string, string>;
}
const [form, setForm] = useState<FormState>({
name: '',
email: '',
errors: {},
});
useRef
// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
// Mutable value ref (no null)
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const countRef = useRef<number>(0);
// Usage
useEffect(() => {
inputRef.current?.focus(); // optional chaining because DOM refs start null
}, []);
useReducer
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; payload: number }
| { type: 'RESET' };
interface CounterState {
count: number;
history: number[];
}
function reducer(state: CounterState, action: Action): CounterState {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1, history: [...state.history, state.count] };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
case 'SET':
return { ...state, count: action.payload }; // TypeScript knows payload exists
case 'RESET':
return { count: 0, history: [] };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });
return (
<div>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'SET', payload: 10 })}>Set 10</button>
</div>
);
}
Custom Hooks
// Return a tuple — type with 'as const'
function useToggle(initial = false): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
// Return an object
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage — TypeScript infers User[] type
const { data, loading, error } = useFetch<User[]>('/api/users');
Generic Components
// A list component that works for any type
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
if (items.length === 0) return <div className="empty-state">{emptyMessage}</div>;
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Usage with TypeScript inference
interface User {
id: string;
name: string;
email: string;
}
function UserList({ users }: { users: User[] }) {
return (
<List<User>
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => <span>{user.name} — {user.email}</span>}
emptyMessage="No users found"
/>
);
}
Event Handler Types
// Common event handler types
function Form() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSubmit(e as any);
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Submit</button>
</form>
);
}
Context with TypeScript
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Create context with a sensible default or undefined
const ThemeContext = React.createContext<ThemeContextValue | undefined>(undefined);
// Custom hook with null check
function useTheme(): ThemeContextValue {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used inside ThemeProvider');
}
return context;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = useCallback(() => {
setTheme(t => (t === 'light' ? 'dark' : 'light'));
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
Common TypeScript Errors in React (and fixes)
| Error | Cause | Fix |
|---|---|---|
Type 'string' is not assignable to type 'never' |
Discriminated union mismatch | Add proper type guards |
Object is possibly 'null' |
useRef or conditional value | Optional chaining ?. or null check |
Property 'X' does not exist on type 'Y' |
Using wrong interface | Check prop types or extend interface |
Cannot invoke an object which is possibly 'undefined' |
Optional function prop | Guard with onClick?.() |
| JSX element type does not have any construct signatures | Passing component as prop without correct type | Use React.ComponentType<Props> |
Quick Reference
// Children
children: React.ReactNode // anything renderable
children: React.ReactElement // must be JSX element
children: React.PropsWithChildren<Props> // adds children to Props
// Refs
ref: React.RefObject<HTMLDivElement> // read-only
ref: React.MutableRefObject<number> // mutable
// Events
onClick: React.MouseEventHandler<HTMLButtonElement>
onChange: React.ChangeEventHandler<HTMLInputElement>
onSubmit: React.FormEventHandler<HTMLFormElement>
// Component types
type FC = (props: Props) => React.ReactElement | null
ComponentType<Props> // class or function component
ElementType // string tag or component
Summary
Strong TypeScript + React practices in 2026:
- Use interfaces for props — more extensible and better error messages
- Discriminated unions for component states — eliminate impossible states
- Type hooks explicitly when TypeScript can't infer (useState with null, useRef)
- Generic components for reusable lists, tables, and form fields
- Extend HTML attributes so custom components still accept native props
- useContext with custom hooks — throw at the hook, not at every callsite
- Avoid
React.FC— it was deprecated in React 18, just type props directly
→ Practice with real TypeScript tools at JSON Viewer and URL Parser.