You don't need to know every exotic TypeScript pattern to be productive with React. You need to know the patterns you'll use every single day.
After three years of writing React with TypeScript across multiple production apps, I've identified the patterns that account for 95% of what you actually write. Master these, and you'll be productive. The other 5%? You can Google when you need it.
This is the guide I wish I'd had when I started.
Pattern 1: Typing Props
This is where everyone starts, and it's the most important pattern to get right.
Functional Component Props
JavaScript:
function Button({ label, onClick, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
TypeScript:
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
}
function Button({ label, onClick, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
With Children
The Problem: What type is children?
// ❌ Wrong - too restrictive
interface CardProps {
children: string;
}
// ❌ Wrong - too loose
interface CardProps {
children: any;
}
// ✅ Correct - accepts any valid React content
interface CardProps {
children: React.ReactNode;
}
function Card({ children }: CardProps) {
return <div className="card">{children}</div>;
}
When to use each children type:
-
React.ReactNode- Any renderable content (most common) -
React.ReactElement- Only React elements (not strings/numbers) -
(props: T) => React.ReactElement- Render function pattern
Optional Props and Defaults
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
}
function Button({
label,
variant = 'primary',
size = 'medium'
}: ButtonProps) {
return (
<button className={`btn-${variant} btn-${size}`}>
{label}
</button>
);
}
Props with Specific String Literals
// ✅ Good - limits to specific values
interface AlertProps {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
}
// ❌ Bad - any string is allowed
interface AlertProps {
type: string;
message: string;
}
Pattern 2: Typing State
Simple State
JavaScript:
const [count, setCount] = useState(0);
const [name, setName] = useState('');
TypeScript:
// Type is inferred automatically
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
When TypeScript can infer the type, let it! Don't over-annotate.
State with Objects
interface User {
id: string;
name: string;
email: string;
}
// ✅ Explicit type when initial value doesn't match
const [user, setUser] = useState<User | null>(null);
// Later:
setUser({ id: '1', name: 'John', email: 'john@example.com' });
State with Arrays
interface Todo {
id: string;
text: string;
completed: boolean;
}
// ✅ Explicit type for empty array
const [todos, setTodos] = useState<Todo[]>([]);
// ✅ Type is inferred when initialized with data
const [todos, setTodos] = useState([
{ id: '1', text: 'Learn TypeScript', completed: false }
]); // Todo[] is inferred
Complex State Patterns
// Union type for state
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
const [state, setState] = useState<LoadingState>({ status: 'idle' });
// TypeScript knows which properties exist based on status
if (state.status === 'success') {
console.log(state.data); // ✓ data exists here
}
if (state.status === 'error') {
console.log(state.error); // ✓ error exists here
}
Pattern 3: Typing Events
This is where TypeScript can feel frustrating if you don't know the patterns.
Click Events
// ✅ Method 1: Inline function (type is inferred)
<button onClick={(e) => console.log(e.currentTarget.value)}>
Click me
</button>
// ✅ Method 2: Separate function with explicit type
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.currentTarget);
}
<button onClick={handleClick}>Click me</button>
Form Events
// Input change
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value);
}
<input onChange={handleChange} />
// Textarea change
function handleTextChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
console.log(e.target.value);
}
<textarea onChange={handleTextChange} />
// Select change
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
console.log(e.target.value);
}
<select onChange={handleSelectChange}>...</select>
Form Submit
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Process form data
}
<form onSubmit={handleSubmit}>...</form>
The Pattern to Remember
React.[EventType]<HTML[ElementType]>
Common combinations:
-
React.MouseEvent<HTMLButtonElement>- Button clicks -
React.ChangeEvent<HTMLInputElement>- Input changes -
React.FormEvent<HTMLFormElement>- Form submits -
React.KeyboardEvent<HTMLInputElement>- Keyboard events -
React.FocusEvent<HTMLInputElement>- Focus/blur events
Pattern 4: Typing Refs
Refs are tricky because they can point to DOM elements or hold mutable values.
DOM Element Refs
// ✅ Correct - specify the element type
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// TypeScript knows this might be null
inputRef.current?.focus();
}, []);
<input ref={inputRef} />
Common element types:
-
HTMLInputElement-<input> -
HTMLTextAreaElement-<textarea> -
HTMLDivElement-<div> -
HTMLButtonElement-<button> -
HTMLFormElement-<form>
Mutable Value Refs
// Storing a mutable value (not a DOM element)
const renderCount = useRef<number>(0);
useEffect(() => {
renderCount.current += 1;
});
Refs with Initial Value
// ✅ When you have an initial value, null is not needed
const timerRef = useRef<number>(0);
// Later
timerRef.current = setTimeout(() => {}, 1000);
Pattern 5: Typing Context
Context requires a bit more setup, but the pattern is consistent.
Basic Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Create context with undefined default
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Provider component
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for consuming context
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Usage
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
Why This Pattern?
- Context might be
undefinedbefore provider mounts - Custom hook throws error if used outside provider
- Consumer components get full type safety
Pattern 6: Typing Custom Hooks
Custom hooks follow the same patterns as regular hooks.
Simple Custom Hook
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// Usage
const { count, increment } = useCounter(10);
Hook with Generics
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const;
}
// Usage - T is inferred from initialValue
const [user, setUser] = useLocalStorage('user', { name: '', age: 0 });
Hook Returning Tuple vs Object
// Tuple - for when order matters (like useState)
function useTuple() {
return [value, setValue] as const;
}
const [val, setVal] = useTuple();
// Object - for better naming and optional destructuring
function useObject() {
return { value, setValue, reset };
}
const { value, reset } = useObject(); // Can skip setValue
Pro tip: Return an object unless you're mimicking built-in hooks like useState.
Pattern 7: Typing useReducer
useReducer is powerful but requires careful typing.
Basic Pattern
// 1. Define state type
interface State {
count: number;
error: string | null;
}
// 2. Define action types using discriminated union
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number }
| { type: 'error'; payload: string };
// 3. Create reducer
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
case 'decrement':
return { ...state, count: state.count - 1 };
case 'reset':
// TypeScript knows payload exists for 'reset'
return { ...state, count: action.payload };
case 'error':
// TypeScript knows payload exists for 'error'
return { ...state, error: action.payload };
default:
return state;
}
}
// 4. Use the reducer
function Counter() {
const [state, dispatch] = useReducer(reducer, {
count: 0,
error: null
});
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset</button>
</div>
);
}
Why Discriminated Unions?
// ❌ Without discriminated union
type Action = {
type: 'increment' | 'decrement' | 'reset';
payload?: number; // Always optional, no safety
};
// TypeScript can't verify payload exists when needed
case 'reset':
return { ...state, count: action.payload }; // Could be undefined!
// ✅ With discriminated union
type Action =
| { type: 'increment' }
| { type: 'reset'; payload: number }; // payload required for reset
case 'reset':
return { ...state, count: action.payload }; // Guaranteed to exist
Pattern 8: Typing Component Props with HTML Attributes
Sometimes you want your component to accept all the same props as a native element.
Extending Native Props
// ✅ Extend button props
interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant: 'primary' | 'secondary';
}
function CustomButton({ variant, ...props }: CustomButtonProps) {
return <button {...props} className={`btn-${variant}`} />;
}
// Now you can use it like a regular button with extra features
<CustomButton
variant="primary"
onClick={handleClick}
disabled={isLoading}
aria-label="Submit form"
/>
Common attribute types:
React.ButtonHTMLAttributes<HTMLButtonElement>React.InputHTMLAttributes<HTMLInputElement>-
React.HTMLAttributes<HTMLDivElement>- Generic div/span/etc React.AnchorHTMLAttributes<HTMLAnchorElement>
Omitting Props
// Remove onClick from the allowed props
interface CustomButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
onPress: () => void; // Use onPress instead
}
Pattern 9: Children Patterns
Different ways to type children for different use cases.
Any Renderable Content
interface CardProps {
children: React.ReactNode; // Most common
}
Single React Element
interface WrapperProps {
children: React.ReactElement; // Must be a single element
}
// ✅ Works
<Wrapper><div>Hello</div></Wrapper>
// ❌ Error - string is not ReactElement
<Wrapper>Hello</Wrapper>
// ❌ Error - multiple elements
<Wrapper>
<div>One</div>
<div>Two</div>
</Wrapper>
Render Function
interface ListProps<T> {
items: T[];
children: (item: T, index: number) => React.ReactNode;
}
function List<T>({ items, children }: ListProps<T>) {
return (
<div>
{items.map((item, index) => (
<div key={index}>{children(item, index)}</div>
))}
</div>
);
}
// Usage
<List items={users}>
{(user, index) => <div>{user.name}</div>}
</List>
Specific Component Type
interface TabsProps {
children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[];
}
Pattern 10: Typing Async Functions
Handling async operations in components.
Async Event Handlers
// ✅ Correct - mark function as async
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
try {
const response = await fetch('/api/data');
const data = await response.json();
setData(data);
} catch (error) {
console.error(error);
}
}
useEffect with Async
// ❌ Wrong - can't make useEffect callback async
useEffect(async () => {
const data = await fetchData();
}, []);
// ✅ Correct - define async function inside
useEffect(() => {
async function loadData() {
const data = await fetchData();
setData(data);
}
loadData();
}, []);
// ✅ Also correct - IIFE
useEffect(() => {
(async () => {
const data = await fetchData();
setData(data);
})();
}, []);
Common Mistakes and How to Fix Them
Mistake 1: Over-using any
// ❌ Defeats the purpose of TypeScript
function handleClick(e: any) { }
// ✅ Use the right type
function handleClick(e: React.MouseEvent<HTMLButtonElement>) { }
Mistake 2: Not Typing Event Handlers
// ❌ No type safety
const handleChange = (e) => { };
// ✅ Type the parameter
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { };
Mistake 3: Forgetting to Handle Null with Refs
const inputRef = useRef<HTMLInputElement>(null);
// ❌ Could be null
inputRef.current.focus();
// ✅ Check for null
inputRef.current?.focus();
// ✅ Or check explicitly
if (inputRef.current) {
inputRef.current.focus();
}
Mistake 4: Not Using Discriminated Unions for State
// ❌ Inconsistent state possible
interface State {
loading: boolean;
data: User | null;
error: Error | null;
}
// ✅ Only valid combinations possible
type State =
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };
Quick Reference Cheat Sheet
// Props
interface Props {
children: React.ReactNode;
}
// State
const [value, setValue] = useState<Type>(initialValue);
// Events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {};
// Refs
const ref = useRef<HTMLDivElement>(null);
// Context
const MyContext = createContext<Type | undefined>(undefined);
// Custom Hooks
function useCustom<T>(param: string): [T, (val: T) => void] {}
// Reducer
type Action = { type: 'action'; payload?: Type };
const reducer = (state: State, action: Action): State => {};
// Extending HTML
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
The 95% Rule
These patterns cover 95% of what you'll write in React + TypeScript:
- Props (with and without children)
- State (simple and complex)
- Events (click, change, submit)
- Refs (DOM elements)
- Context (with custom hook)
- Custom hooks (returning objects or tuples)
- useReducer (with discriminated unions)
- Extending HTML attributes
- Async operations
Master these, and you'll be productive. The exotic patterns? Google them when you need them.
One Last Tip
Let TypeScript infer when it can. Don't over-annotate:
// ❌ Unnecessary - TypeScript knows this is a number
const [count, setCount] = useState<number>(0);
// ✅ Let it infer
const [count, setCount] = useState(0);
// ✅ Only annotate when needed
const [user, setUser] = useState<User | null>(null);
TypeScript is smartest when you trust its inference. Add types only when you need to:
- Override inference (like
nullinitial state) - Define the shape (like component props)
- Add constraints (like event handler types)
These patterns are your foundation. Build on them as needed, but come back to them often—they're your 95%.
Top comments (0)