React with TypeScript: Typing Components, Hooks, and Props
TypeScript adds static type checking to React, catching bugs at compile time rather than runtime. When combined, TypeScript and React provide autocompletion, refactoring support, and documentation through types.
Typing React Components
Function Components with Props
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
---
function Button({ label, onClick, variant = 'primary', disabled }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
---Typing Children
interface CardProps {
title: string;
children: React.ReactNode;
---
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
---For typed children slots, use React.ReactElement for a single element or React.ReactNode for any renderable content.
Typing React Patterns
TypeScript with React requires specific type patterns for common constructs. Use React.FC<Props> for function components with children, though many teams prefer explicit { children?: React.ReactNode } for clarity. Template literal types create event handler types: React.ChangeEvent<HTMLInputElement> for input changes. Generic components allow reusable typing: function List<T>({ items }: { items: T[] }). The useRef hook requires useRef<HTMLDivElement>(null) with the initial value matching the type argument.
Typing Hooks
useState
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);TypeScript infers the type from the initial value. Provide an explicit type for union types or null states.
useRef
const inputRef = useRef<HTMLInputElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>();useRef<HTMLInputElement>(null) creates a mutable ref object. The null initial value is acceptable because the ref may not be attached yet.
useReducer
interface State { count: number; }
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
}
---
const [state, dispatch] = useReducer(reducer, { count: 0 });Discriminated unions for actions ensure exhaustive handling in the reducer switch statement.
useContext
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
---
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
---Generic Components
Generic components work with multiple data types while preserving type safety:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
---
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
---
// Usage — TypeScript infers T
<List
items={users}
renderItem={(user) => <span>{user.name}</span>}
keyExtractor={(user) => user.id}
/>Constrained Generics
interface HasId { id: string; }
function EntityList<T extends HasId>({ items }: { items: T[] }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{/* T is guaranteed to have 'id' */}</li>
))}
</ul>
);
---Type Safety with State Management
Zustand, Redux Toolkit, and Jotai all provide TypeScript-first APIs. Define state slices with typed actions and reducers for compile-time safety. Discriminated unions model complex state shapes where the current state determines available properties. The satisfies operator (TS 4.9+) validates that a type meets a constraint without widening.
Redux Toolkit Example
interface CounterState { value: number; }
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 } as CounterState,
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
},
---);
// Typed hooks
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Event Handlers
function Form() {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// submit logic
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log('button clicked at', e.clientX, e.clientY);
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
<button onClick={handleClick}>Submit</button>
</form>
);
---Type-Safe Event Handlers
React event types provide precise typing for different DOM events. Use React.ChangeEvent<HTMLInputElement> for input change handlers, React.FormEvent<HTMLFormElement> for form submit handlers, React.MouseEvent<HTMLButtonElement> for click handlers, and React.KeyboardEvent<HTMLDivElement> for keyboard events. For custom events in compound components, define event payload types and use generics: type ChangeHandler<T> = (value: T) => void. Generic event handlers propagate the element type through the component hierarchy for end-to-end type safety.
Type Narrowing Patterns
TypeScript’s control flow analysis narrows union types within conditionals. For discriminated unions, use a type or kind field as the discriminant: if (action.type === 'success') { action.data } auto-completes the correct data type. Use type guards — functions that return value is Type — to encapsulate complex narrowing logic: function isError(result: Result): result is ErrorResult. The in operator narrows types by checking property existence: if ('error' in response). Assertion functions (asserts value is Type) throw if the type doesn’t match. Master these patterns to write type-safe code without as casts.
FAQ
Should I use React.FC or interface for component props?
Many TypeScript + React codebases avoid React.FC because it implicitly includes children in the type (before React 18) and doesn’t support generic components well. Using explicit interface Props with destructured props is the preferred pattern for flexibility and clarity.
How do I type a component that forwards refs?
Use React.forwardRef with a generic type: const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => <button ref={ref} {...props} />). The type parameter for forwardRef is the ref type, and the second parameter is the props type.
How do I type children props properly?
Use React.ReactNode (includes strings, elements, fragments, portals). For single-element requirements, use React.ReactElement. For render props, use functions: children: (value: T) => React.ReactNode. For typed slot patterns, use object types with specific keys.
Generic Component Patterns
Generic components create reusable, type-safe abstractions. Use <T> on the component function to parameterize props: function List<T,>({ items, render }: ListProps<T>). Constrain generics with extends for type safety: <T extends { id: string }>. Use React.forwardRef with generics for components that need ref forwarding: const Input = forwardRef<HTMLInputElement, InputProps>(...). For higher-order components, use generic wrappers that preserve prop types: function withLogger<T>(Component: React.ComponentType<T>). These patterns eliminate any casts while keeping flexibility.
Conditional Types and Mapped Types
Advanced TypeScript patterns create reusable type transformations. Conditional types select between types based on a condition: type IsString<T> = T extends string ? true : false. Mapped types transform object types: type Readonly<T> = { readonly [K in keyof T]: T[K] }. Template literal types create string patterns: type EventName<T> = \on${Capitalize. The inferkeyword extracts types from other types:type Return
Context and Provider Types
Type React context with discriminated unions for type-safe state management. Define a reducer type with action discriminators: type Action = { type: 'increment' } | { type: 'set', payload: number }. The useReducer hook infers the dispatch type from the reducer function. For the Context API, create a typed context with React.createContext<State | null>(null) and a custom useContext hook that throws if used outside the provider. This eliminates null checks throughout your components while ensuring context is properly initialized before use.
Error Handling with Custom Error Types
Type discriminated error types for comprehensive error handling. Define a union type for all possible errors: type ApiError = NetworkError | AuthError | ValidationError | ServerError. Each variant has a kind discriminant and relevant data. Use type guards to narrow: if (error.kind === 'validation') { error.fields }. Create a custom Result<T, E> type similar to Rust’s Result: type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }. This pattern makes error handling explicit and type-safe throughout the application.
CSS-in-JS with TypeScript
Type-safe CSS-in-JS libraries provide autocomplete for CSS properties. Emotion’s css prop accepts typed styles: css={{ color: 'red' }} with full IntelliSense. Styled components infer props from the component type. CSS modules with TypeScript require type declarations: declare module '*.module.css'. CSS types help catch typos in style properties and ensure values match expected units. For theme objects, define a Theme interface and augment the ThemeProvider type for type-safe theme access across your application.
Exhaustive Type Checking with never
Use the never type for exhaustive switch statements and conditional checks. In a discriminated union, add a default case that assigns to never: const _exhaustive: never = action;. TypeScript errors if any union variant is unhandled because the unhandled type is not assignable to never. This catch-all ensures you always handle all variants when adding new types. Use the same pattern with if-else chains covering discriminated unions for type-safe branching throughout your state management code.
What is the best way to share types between frontend and backend?
Place shared types in a separate package or monorepo workspace (e.g., packages/shared/). GraphQL codegen tools (GraphQL Code Generator) automatically generate TypeScript types from your GraphQL schema. For REST APIs, consider OpenAPI-to-TypeScript generators.
For a comprehensive overview, read our article on Next Js Guide.
For a comprehensive overview, read our article on React Authentication.