React and TypeScript

Introduction
TypeScript has become the de-facto standard for React projects. Strict typing helps catch errors at the compilation stage, makes the code self-documenting, and significantly improves the Developer Experience thanks to autocomplete and code navigation.
In this article, we'll break down all key aspects of typing in React from basic props to advanced generic components and polymorphic patterns.
Basics of Typing in React
Typing Props
Props are the foundation of typing React components. Use type or interface:
type ButtonProps = {
label: string;
variant?: 'primary' | 'secondary'; // union literal
disabled?: boolean;
onClick: () => void;
};
const Button = ({ label, variant = 'primary', onClick }: ButtonProps) => (
<button className={variant} onClick={onClick}>{label}</button>
);
The difference between interface and type: in practice, they are interchangeable for props. interface is more convenient if you need extension via extends, while type is for unions (|) and intersections (&). Most teams choose one approach and stick to it.
Generics - for generic components:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map(renderItem)}</ul>;
}
Discriminated unions - when a set of props depends on a variant:
type AlertProps =
| { variant: "simple"; message: string }
| { variant: "withAction"; message: string; onAction: () => void };
Types of children
React provides several types for children, each with a different degree of strictness:
| Type | Accepts | When to use |
|---|---|---|
React.ReactNode | Strings, numbers, elements, null, arrays | In most cases |
React.ReactElement | Only JSX elements | Strict control |
(data: T) => ReactNode | Function (render props) | render props pattern |
Events
React provides typed handlers for all DOM events:
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { };
Pattern: React.[EventType]Event<HTML[Element]Element> - universal format for events.
Typing Hooks
useState
TypeScript automatically infers the type from the initial value. Specify the type explicitly when the initial value does not reveal the full type:
const [count, setCount] = useState(0); // number
const [user, setUser] = useState<User | null>(null); // explicit type
useRef
Two main scenarios: DOM references and mutable values:
const inputRef = useRef<HTMLInputElement>(null); // DOM
const timerRef = useRef<number>(0); // mutable value
useReducer
Discriminated union for actions is the key pattern:
type State = { count: number; loading: boolean };
type Action =
| { type: 'increment' }
| { type: 'set'; payload: number };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment': return { ...state, count: state.count + 1 };
case 'set': return { ...state, count: action.payload };
}
};
useMemo and useCallback
Types are inferred automatically. Specify the type of callback arguments:
const doubled = useMemo(() => count * 2, [count]);
const handleClick = useCallback((id: string) => { /* ... */ }, []);
Custom Hooks
The most interesting part is here - typing makes the hook API clear:
// Simple hook
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle] as const;
// ^^^^^^^^ without as const, the type will be (boolean | () => void)[]
// with it ā readonly [boolean, () => void]
}
// Hook with a generic
function useLocalStorage<T>(key: string, fallback: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : fallback;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage ā the type is inferred from fallback
const [theme, setTheme] = useLocalStorage("theme", "dark");
// ^string ^(value: string) => void
Type-safe Context
The main problem with Context in TypeScript is the initial value. There are two approaches, but the first one is recommended:
Approach A: undefined + custom hook (recommended)
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx; // type without undefined
};
Universal Context Factory
Gets rid of repetitive boilerplate:
function createStrictContext<T>(name: string) {
const Context = createContext<T | undefined>(undefined);
const useStrictContext = (): T => {
const ctx = useContext(Context);
if (ctx === undefined)
throw new Error(`use${name} must be used within ${name}Provider`);
return ctx;
};
return [Context.Provider, useStrictContext] as const;
}
// Usage
const [AuthProvider, useAuth] = createStrictContext<AuthContext>('Auth');
Important: Never use
{} as ContextType- it hides runtime errors.
Generic components
Generics allow you to create reusable components with type inference:
type SelectProps<T> = {
items: T[];
selected: T;
getLabel: (item: T) => string;
onChange: (item: T) => void;
};
const Select = <T,>({ items, selected, getLabel, onChange }: SelectProps<T>) => (
<select value={String(selected)}
onChange={(e) => onChange(items[e.target.selectedIndex])}
>
{items.map((item, i) => (
<option key={i}>{getLabel(item)}</option>
))}
</select>
);
When used, TypeScript itself will infer type T:
<Select
items={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
selected={{ id: 1, name: 'Alice' }}
getLabel={(u) => u.name} // u is typed automatically
onChange={(u) => console.log(u.id)}
/>
Comma after
T: The syntax<T,>is needed in .tsx files so that the parser can distinguish a generic from a JSX tag.
Restricting generic via extends.
When a component should work not with any type, but only with those that have certain fields:
interface HasId {
id: string | number;
}
interface DataListProps<T extends HasId> {
items: T[];
onSelect: (item: T) => void;
renderItem: (item: T) => React.ReactNode;
}
function DataList<T extends HasId>({ items, onSelect, renderItem }: DataListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Works ā User has id
interface User { id: number; name: string; }
<DataList
items={users}
onSelect={(user) => console.log(user.name)} // user is typed as User
renderItem={(user) => <span>{user.name}</span>}
/>
// Compilation error ā { title: string } has no id
<DataList items={[{ title: "oops" }]} ... />
Generic + forwardRef
forwardRef complicates the syntax because it does not support generics directly. Workaround:
// Method 1: Type casting via as
const GenericList = forwardRef(function GenericList<T>(
props: ListProps<T>,
ref: React.ForwardedRef<HTMLUListElement>
) {
return <ul ref={ref}>{/* ... */}</ul>;
}) as <T>(props: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) => React.ReactElement;
// Method 2 (easier): Move ref to prop
interface ListProps<T> {
items: T[];
listRef?: React.Ref<HTMLUListElement>;
}
function List<T>({ items, listRef }: ListProps<T>) {
return <ul ref={listRef}>{/* ... */}</ul>;
}
Generic components are justified when the same UI pattern is applied to different data types: lists, tables, selects, forms, modals with different contents. If a component operates with only one type - a generic is overkill, a regular props interface will be simpler and clearer.
Why to give up on React.FC
Type React.FC (aka React.FunctionComponent) has long been the standard way to type components. However, the React community has come to a consensus: you shouldn't use it.
Here are the main reasons:
Problems with React.FC
⢠Implicit addition of children (before React 18). Any component accepted children even if it shouldn't, hiding bugs.
⢠Inability to use generics. The React.FC syntax does not allow parameterizing a component with type T.
⢠Incorrect work with defaultProps. TypeScript could not infer types from defaultProps when using React.FC.
⢠Redundant noise in code. React.FC gives nothing useful, it only lengthens the component definition.
⢠Limitation of the return type. React.FC forces ReactElement | null, although a component can return a string, number, etc.
What to use instead of React.FC Type props directly via the function argument:
// ā Before
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
// ā
After
const Button = ({ label, onClick }: ButtonProps) => {
return <button onClick={onClick}>{label}</button>;
};
If a component needs children, simply specify it explicitly in the props type:
type CardProps = {
title: string;
children: React.ReactNode;
};
const Card = ({ title, children }: CardProps) => (
<div><h2>{title}</h2>{children}</div>
);
Tip: Explicitly specifying children makes the component API transparent and prevents accidentally passing child elements to where they are not expected.
Extending HTML elements
For wrapper components over native elements, use ComponentProps:
type InputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> & {
label: string;
error?: string;
size?: 'sm' | 'md' | 'lg';
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, size = 'md', className, ...rest }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} className={`input-${size} ${className ?? ''}`} {...rest} />
{error && <span className="error">{error}</span>}
</div>
),
);
| Utility | Description |
|---|---|
ComponentPropsWithRef<'div'> | All div props + ref |
ComponentPropsWithoutRef<'div'> | All div props without ref |
ComponentProps<typeof MyComp> | Extract props of a component |
Polymorphic components (as-prop)
A pattern where a component can be rendered as any HTML element, with automatic adjustment of allowed props:
type BoxProps<T extends React.ElementType> = {
as?: T;
children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children'>;
const Box = <T extends React.ElementType = 'div'>({
as, children, ...rest
}: BoxProps<T>) => {
const Component = as || 'div';
return <Component {...rest}>{children}</Component>;
};
// Types adjust automatically:
<Box as="a" href="/home">Link</Box> // href available
<Box as="button" onClick={() => {}}>Btn</Box> // onClick available
Best practice
Strict tsconfig
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Key option:
noUncheckedIndexedAccessmakes arr[0] typeT|undefinedinstead ofT- prevents runtime errors.
as const instead of enum
// ā enum ā problems with tree-shaking, redundant runtime code
enum Status { Idle = 'idle', Loading = 'loading' }
// ā
as const + type inference
const STATUS = {
Idle: 'idle',
Loading: 'loading',
Error: 'error',
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
// 'idle' | 'loading' | 'error'
Discriminated Unions for states
Instead of a set of boolean flags, describe each state as a separate union variant:
// ā Bad ā impossible combinations are allowed
type BadState = {
loading: boolean;
error: string | null;
data: User[] | null;
};
// ā
Good ā each state is accurately described
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
Exhaustive check
Guarantees that when adding a new variant to a union, the compiler will point to all unhandled cases:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
const getLabel = (status: Status): string => {
switch (status) {
case 'idle': return 'Ready';
case 'loading': return 'Loading...';
case 'error': return 'Failed';
default: return assertNever(status); // error if we forgot a case
}
};
Template Literal Types for APIs
A powerful pattern for typing HTTP clients:
type ApiEndpoints = {
'GET /users': { response: User[] };
'POST /users': { response: User; body: CreateUserDto };
'DELETE /users/:id': { response: void; params: { id: string } };
};
async function api<E extends keyof ApiEndpoints>(
endpoint: E,
...args: ApiEndpoints[E] extends { body: infer B } ? [body: B] : []
): Promise<ApiEndpoints[E]['response']> { /* ... */ }
const users = await api('GET /users'); // User[]
unknown instead of any
// ā any disables checks ā errors slip through
function parse(input: any) {
return input.name.toUpperCase(); // no errors, runtime crash
}
// ā
unknown forces a check prior to use
function parse(input: unknown) {
if (typeof input === "object" && input !== null && "name" in input) {
return (input as { name: string }).name.toUpperCase();
}
throw new Error("Invalid input");
}
satisfies - checking without losing the literal type
// ā annotation widens the type
const routes: Record<string, { path: string }> = {
home: { path: "/" },
about: { path: "/about" },
};
routes.typo; // no error ā key is string
// ā
satisfies checks structure, but keeps exact keys
const routes = {
home: { path: "/" },
about: { path: "/about" },
} satisfies Record<string, { path: string }>;
routes.typo; // compilation error
routes.home.path; // type is "/"
Extending HTML elements via ComponentPropsWithoutRef
// ā Manually duplicating all button props
interface ButtonProps {
variant: "primary" | "secondary";
disabled?: boolean;
onClick?: () => void;
// ... another 40 props?
}
// ā
Inherit from HTML and add our own
type ButtonProps = {
variant: "primary" | "secondary";
} & React.ComponentPropsWithoutRef<"button">;
function Button({ variant, className, ...rest }: ButtonProps) {
return <button className={`btn-${variant} ${className ?? ""}`} {...rest} />;
}
Cheat sheet: Do & Don't
| ā Do | ā Avoid |
|---|---|
strict: true in tsconfig | Disabling strict |
as const objects | enum |
| Discriminated unions for states | { loading: boolean; data: T | null } |
| Zod / Valibot for runtime validation | Blindly trusting as T from API |
type for props | React.FC |
ComponentPropsWithoutRef<'div'> | Manually listing native props |
satisfies for type check without widening | as casting without necessity |
assertNever in switch | Ignoring default |
noUncheckedIndexedAccess | arr[0] without undefined check |
Conclusion
Typing is a design tool. When you describe a type, you formulate a contract: what a component accepts, what it returns, what states are possible. It forces you to think through the architecture before writing logic, not after.
Good types catch errors before running, help IDEs suggest and document component contracts without redundant comments. Start with a strict tsconfig, describe states through unions, validate external data at the boundaries - and trust type inference where TypeScript manages on its own.