React Component Types: Generics, Hooks, Event Handlers
Why Not React.FC
React.FC (alias for React.FunctionComponent) used to be the standard way to write typed React components. The community and official React docs have moved away from it. The reasons are concrete.
// The React.FC approach
const Greeting: React.FC<{ name: string }> = ({ name }) => {
return <div>Hello, {name}</div>;
};
React.FC had an implicit behavior: it automatically added children: React.ReactNode to the props type. Versions before React 18 kept this behavior. That meant:
// The implicit children problem with React.FC (React 17 and earlier)
const Button: React.FC<{ label: string }> = ({ label }) => (
<button>{label}</button>
);
// This compiles even though Button was not designed to accept children
<Button label="Click me"><span>extra</span></Button>;
React 18 fixed this (React.FC no longer implicitly includes children), but the episode revealed the larger issue: React.FC provides no additional type safety. It is just a type annotation that is more verbose than writing a plain function.
Recommended: plain functions
interface GreetingProps {
name: string;
age?: number;
}
function Greeting({ name, age }: GreetingProps) {
return <div>Hello, {name}{age ? `, age ${age}` : ''}</div>;
}
// Arrow function is fine too
const Greeting = ({ name, age }: GreetingProps) => (
<div>Hello, {name}</div>
);
The return type (JSX.Element) is inferred by the compiler โ no explicit annotation needed.
Props Typing: Children, Events, Refs
Typing children correctly
import { ReactNode, PropsWithChildren } from 'react';
// Option 1: declare children manually
interface CardProps {
title: string;
children: ReactNode; // the most permissive children type
}
// Option 2: use the PropsWithChildren utility type
interface CardBaseProps {
title: string;
}
type CardProps = PropsWithChildren<CardBaseProps>;
// equivalent to { title: string; children?: ReactNode }
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="content">{children}</div>
</div>
);
}
ReactNode covers ReactElement | string | number | boolean | null | undefined | ReactPortal โ essentially anything renderable. If you need exactly one React element (no strings or numbers), use ReactElement.
Event handler types
import { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from 'react';
interface ButtonProps {
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
onDoubleClick?: (event: MouseEvent<HTMLButtonElement>) => void;
label: string;
}
interface InputProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
value: string;
}
interface FormProps {
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
function SearchInput({ onChange, value }: InputProps) {
return (
<input
type="text"
value={value}
onChange={onChange}
// onChange type is (event: ChangeEvent<HTMLInputElement>) => void
// which matches exactly what the input element expects
/>
);
}
The generic T in ChangeEvent<T> is the DOM element type. event.target gets the type T, so event.target.value is only accessible when T is HTMLInputElement, HTMLTextAreaElement, or another element that has a value property.
// Common event types at a glance
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
e.target.value; // string
};
const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
e.target.value; // string (the value of the selected option)
e.target.selectedIndex; // number
};
const handleTextareaChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
e.target.value; // string
};
Typing useRef
useRef has two entirely different use cases. Their type signatures differ.
Use case 1: holding a DOM reference
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
// useRef<HTMLInputElement>(null) โ initial value must be null
// type: RefObject<HTMLInputElement> (ref.current may be null)
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// ref.current can be null here โ must check
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
// The initial value matters:
const ref1 = useRef<HTMLInputElement>(null);
// type: React.RefObject<HTMLInputElement>
// ref1.current is HTMLInputElement | null (read-only, React manages it)
const ref2 = useRef<HTMLInputElement | null>(null);
// type: React.MutableRefObject<HTMLInputElement | null>
// ref2.current is writable (you can assign to it manually)
Use case 2: holding a mutable value (without triggering re-renders)
function Timer() {
// Store an interval ID โ no re-render needed
const intervalId = useRef<ReturnType<typeof setInterval> | null>(null);
const start = () => {
intervalId.current = setInterval(() => {
console.log('tick');
}, 1000);
};
const stop = () => {
if (intervalId.current !== null) {
clearInterval(intervalId.current);
intervalId.current = null;
}
};
return (
<div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
Key distinction: for DOM refs use useRef<ElementType>(null); for mutable values use useRef<ValueType>(initialValue).
useState with Complex Types
import { useState } from 'react';
// Simple types: inferred automatically, no generic needed
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [visible, setVisible] = useState(false); // boolean
// Complex types: explicit generic required because the initial value
// does not carry enough information to infer the full type
interface User {
id: string;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
// user type: User | null
// setUser type: Dispatch<SetStateAction<User | null>>
// Array state
const [items, setItems] = useState<string[]>([]);
// Without the generic, items would be typed as never[]
// Union type state
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
// setStatus('typo') is now a compile error
// Object state
interface FormState {
username: string;
password: string;
rememberMe: boolean;
}
const [form, setForm] = useState<FormState>({
username: '',
password: '',
rememberMe: false,
});
const handleChange = (field: keyof FormState, value: FormState[keyof FormState]) => {
setForm(prev => ({ ...prev, [field]: value }));
};
useReducer with Discriminated Union Actions
import { useReducer } from 'react';
interface CartState {
items: CartItem[];
total: number;
isLoading: boolean;
}
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
// Discriminated union action โ each action has a unique type literal
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'CLEAR_CART' };
// The reducer: TypeScript narrows action to the specific branch type
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// action.payload type: CartItem
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price * action.payload.quantity,
};
case 'REMOVE_ITEM':
// action.payload type: { id: string }
const filtered = state.items.filter(item => item.id !== action.payload.id);
return {
...state,
items: filtered,
total: filtered.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
case 'UPDATE_QUANTITY':
// action.payload type: { id: string; quantity: number }
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
};
case 'SET_LOADING':
// action.payload type: boolean
return { ...state, isLoading: action.payload };
case 'CLEAR_CART':
// no payload
return { items: [], total: 0, isLoading: false };
default:
// Exhaustiveness check: if all cases are handled, this is never
const _exhaustive: never = action;
return state;
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
isLoading: false,
});
// dispatch argument is fully typed as CartAction
dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Book', price: 29.9, quantity: 1 } });
dispatch({ type: 'CLEAR_CART' }); // no payload needed
// dispatch({ type: 'WRONG' }) โ compile error
}
Strictly Typed useContext
import { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Option 1: non-null assertion (acceptable when Provider is guaranteed)
const ThemeContext = createContext<ThemeContextValue>(
null as unknown as ThemeContextValue // forced initial value
);
// Option 2: custom hook with runtime check (safer)
const ThemeContext2 = createContext<ThemeContextValue | null>(null);
function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext2);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context; // type: ThemeContextValue (null excluded)
}
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light');
return (
<ThemeContext2.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext2.Provider>
);
}
function ThemedButton() {
const { theme, toggleTheme } = useTheme();
// theme type: 'light' | 'dark'
return (
<button
onClick={toggleTheme}
style={{ background: theme === 'dark' ? '#333' : '#fff' }}
>
Switch to {theme === 'dark' ? 'light' : 'dark'}
</button>
);
}
Custom Hooks: Return Type Inference
import { useState, useEffect, useCallback } from 'react';
// Return type inferred by the compiler
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: T = await res.json();
setData(json);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Inferred return type:
// { data: T | null; loading: boolean; error: Error | null; refetch: () => Promise<void> }
return { data, loading, error, refetch: fetchData };
}
// Pass the expected data type when using the hook
interface User {
id: string;
name: string;
}
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
// data type: User | null โ fully type-safe
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>{data.name}</div>;
}
Explicit return type (better for public APIs)
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
function useFetch<T>(url: string): UseFetchResult<T> {
// ...same as above
}
Generic Components: The <T,> Syntax Quirk
Generic components let you write reusable lists, tables, and selects while preserving type safety.
import { ReactNode } from 'react';
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
// Note the trailing comma in <T,>
// In a .tsx file, <T> is parsed as a JSX opening tag
// The comma signals: this is a generic parameter, not JSX
function List<T,>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
if (items.length === 0) return <div>{emptyMessage}</div>;
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Type is inferred at the call site โ no manual annotation
interface User {
id: string;
name: string;
email: string;
}
function UserList({ users }: { users: User[] }) {
return (
<List
items={users}
keyExtractor={user => user.id}
// item is inferred as User โ no annotation needed
renderItem={user => <span>{user.name} ({user.email})</span>}
/>
);
}
Generic components with constraints
function SortableList<T extends { id: string; order: number }>(
{ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }
) {
const sorted = [...items].sort((a, b) => a.order - b.order);
return (
<ul>
{sorted.map(item => <li key={item.id}>{renderItem(item)}</li>)}
</ul>
);
}
Alternatives to avoid the .tsx parsing ambiguity
// Use extends to remove ambiguity
function Select<T extends object>(props: SelectProps<T>) { ... }
// Or write the component in a .ts file (no JSX), import it in .tsx
Typing forwardRef
import { forwardRef, useImperativeHandle, useRef } from 'react';
interface InputProps {
label: string;
error?: string;
onChange: (value: string) => void;
}
// forwardRef generics: first is the ref type, second is the props type
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, onChange }, ref) => {
return (
<div>
<label>{label}</label>
<input
ref={ref}
onChange={e => onChange(e.target.value)}
aria-invalid={!!error}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
);
Input.displayName = 'Input';
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div>
<Input
ref={inputRef}
label="Username"
onChange={value => console.log(value)}
/>
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</div>
);
}
useImperativeHandle: custom exposed API
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (seconds: number) => void;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(
({ src }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (seconds) => {
if (videoRef.current) videoRef.current.currentTime = seconds;
},
}));
return <video ref={videoRef} src={src} />;
}
);
// The ref type is VideoPlayerHandle, not HTMLVideoElement
function MoviePage() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/movie.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.seek(30)}>Skip 30s</button>
</div>
);
}
Higher-Order Components
import { ComponentType } from 'react';
function withAuth<P extends { userId?: string }>(
WrappedComponent: ComponentType<P>
) {
type PublicProps = Omit<P, 'userId'>;
return function AuthenticatedComponent(props: PublicProps) {
const { userId } = useAuth();
if (!userId) {
return <div>Please log in</div>;
}
return <WrappedComponent {...(props as P)} userId={userId} />;
};
}
interface DashboardProps {
userId: string;
title: string;
}
function Dashboard({ userId, title }: DashboardProps) {
return <div>User {userId}: {title}</div>;
}
const AuthDashboard = withAuth(Dashboard);
// AuthDashboard only requires { title: string } โ userId is injected by the HOC
<AuthDashboard title="My Dashboard" />;
Anti-Patterns
| Anti-pattern | Problem | Correct approach |
|---|---|---|
React.FC on plain components |
Historical baggage; implicit children before React 18 | Plain function + explicit Props interface |
Event types written as any |
Lose type info on event.target |
Use ChangeEvent<HTMLInputElement> etc. |
useState(null) without a generic |
Type inferred as null; later assignments fail |
useState<User | null>(null) |
Not checking useRef.current for null |
Runtime TypeError | ref.current?.method() or explicit if-check |
<T> generic in a .tsx file |
Parsed as JSX, syntax error | <T,> or <T extends unknown> |
| No return type on hook (public API) | Inferred type leaks implementation details | Define an explicit interface HookResult |
| Using context without null check | useContext returns null outside Provider |
Custom hook with runtime null check |
Summary
| Scenario | Recommended approach |
|---|---|
| Component definition | Plain function + Props interface |
| DOM ref | useRef<HTMLXxxElement>(null) |
| Mutable value ref | useRef<T>(initialValue) |
| Complex state | useState<T>(initial) with explicit generic |
| Multi-action reducer | Discriminated union Action + exhaustive switch |
| Context | Custom hook + runtime null check |
| Generic component (.tsx) | function Comp<T,> or extends syntax |
| Ref forwarding | forwardRef<RefType, Props> |
Next chapter covers full-stack type safety: how tRPC shares types between server and client without code generation, and how Prisma and Drizzle ORM derive types from your data model.