· 5 min read
Using React with TypeScript: Tips and Tricks
Practical, example-driven guide to using TypeScript with React. Covers component typing, hooks, refs, generics, polymorphic components, utility types, and tooling tips to make your React code safer and more maintainable.
Introduction
TypeScript brings powerful static typing to React apps, helping you catch bugs earlier, document intent, and build more maintainable UIs. This article focuses on practical tips and tricks to get the most out of React + TypeScript: from component and event typing to advanced patterns like generics, polymorphic components and typed refs. Code samples target modern React (function components, hooks) and TypeScript strict settings.
Why use TypeScript with React?
- Better autocompletion and editor tooling.
- Fewer runtime surprises through compile-time checks.
- Clearer, self-documenting APIs for components.
Quick setup notes
- Create a project (Vite / Create React App / Next.js all have templates):
- Vite:
npm create vite@latest my-app --template react-ts
- Vite:
- Recommended tsconfig settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
- Use ESLint + typescript-eslint + eslint-plugin-react-hooks for linting.
Typing components: basic patterns
- Prefer explicit prop types rather than
any
. - Avoid
React.FC
when you need more precise control (esp. for generics and defaultProps quirks). You can still use it when you want an implicitchildren
type, but many teams prefer explicitchildren
.
Example: typed function component with props and children
type ButtonProps = {
children?: React.ReactNode;
variant?: 'primary' | 'secondary';
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
function Button({ children, variant = 'primary', onClick }: ButtonProps) {
return (
<button className={`btn-${variant}`} onClick={onClick}>
{children}
</button>
);
}
Component props from intrinsic elements
When wrapping native elements, reuse DOM prop types to avoid reinventing the wheel:
type InputProps = React.ComponentProps<'input'> & {
label?: string;
};
const Input = ({ label, ...rest }: InputProps) => (
<label>
{label}
<input {...rest} />
</label>
);
Events: use the right event types
- use React.ChangeEvent
for inputs - React.FormEvent
for forms - React.MouseEvent
for button clicks
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
}
useState and lazy initialization
Type inference often works well, but annotate when the state can be null or multiple types:
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
// lazy init
const [heavy, setHeavy] = React.useState(() => expensiveComputation());
useRef: DOM refs and mutable values
Always type refs. DOM refs are often nullable.
const inputRef = React.useRef<HTMLInputElement | null>(null);
function focus() {
inputRef.current?.focus();
}
Forwarding refs with types
If your component forwards refs, type both the ref and props:
type FancyButtonProps = {
children?: React.ReactNode;
} & React.ComponentProps<'button'>;
const FancyButton = React.forwardRef<HTMLButtonElement, FancyButtonProps>(
({ children, ...rest }, ref) => (
<button ref={ref} {...rest}>
{children}
</button>
)
);
FancyButton.displayName = 'FancyButton';
useReducer typed reducer
type 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 };
default:
return state;
}
}
const [state, dispatch] = React.useReducer(reducer, { count: 0 });
Custom hooks with generics
Generics let you write reusable hooks that keep strong typings.
function useLocalStorage<T>(key: string, initialValue: T) {
const [state, setState] = React.useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initialValue;
});
React.useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState] as const; // preserves tuple types
}
const [value, setValue] = useLocalStorage<number>('count', 0);
Polymorphic components (a powerful pattern)
Polymorphic components accept an as
prop to render different elements while preserving the correct props. This pattern is slightly more advanced.
import React from 'react';
type AsProp<C extends React.ElementType> = { as?: C };
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> &
P) extends never
? never
: keyof (AsProp<C> & P);
type PolymorphicComponentProps<
C extends React.ElementType,
Props = {},
> = Props & AsProp<C> & Omit<React.ComponentPropsWithoutRef<C>, keyof Props>;
type TextProps = { size?: 'sm' | 'md' | 'lg' };
const Text = <C extends React.ElementType = 'span'>(
{ as, size = 'md', ...rest }: PolymorphicComponentProps<C, TextProps>
// ref is omitted here for brevity
) => {
const Component = (as || 'span') as React.ElementType;
return <Component {...rest} className={`text-${size}`} />;
};
Discriminated unions for mutually-exclusive props
When a component accepts variants that require different props, use discriminated unions to get exhaustive checking.
type LinkProps =
| { href: string; external?: false }
| { href?: undefined; onClick: () => void };
function NavItem(props: LinkProps) {
if ('onClick' in props) {
return <button onClick={props.onClick}>Action</button>;
}
return (
<a
href={props.href}
target={props.external ? '_blank' : undefined}
rel={props.external ? 'noreferrer' : undefined}
>
Link
</a>
);
}
Utility types to remember
- Omit<T, K>
- Pick<T, K>
- Partial
, Required - Readonly
- Record<K, T>
- ReturnType
, Parameters - ComponentProps<‘button’>, ComponentPropsWithRef<‘input’>
Higher-order components (HOCs)
Typing HOCs can be tricky. A common pattern is to use generics and preserve props by intersection:
function withLogger<P extends object>(Component: React.ComponentType<P>) {
return (props: P) => {
console.log('props', props);
return <Component {...props} />;
};
}
Type-only imports and performance
Use import type { Foo } from './types'
for type-only imports to make intent clear and help with certain build setups.
Tips and best practices
- Use
strict: true
in tsconfig - it pays off. - Prefer explicit prop types over
any
or spreading unknown props without types. - When possible, let TypeScript infer types but annotate public APIs.
- Use
as const
for literal inference (e.g., const variants = [‘a’, ‘b’] as const). - Use linters: eslint + @typescript-eslint + eslint-plugin-react-hooks.
- Avoid non-null assertions (
!
) unless you truly know a value is present. - When interacting with external data (APIs), validate runtime shapes (e.g., with Zod or [io-ts]) because TypeScript types are erased at runtime.
Common pitfalls and how to avoid them
- Mistyped event handlers: use correct event types (ChangeEvent, MouseEvent, FormEvent).
- Ref types: always include
| null
for DOM refs. - Generic components: ensure default type parameters when reasonable to preserve ergonomics.
- Using React.FC: it defines children implicitly and affects return type inference; many teams prefer plain functions for clarity.
Tooling and learning resources
- React + TypeScript Cheatsheet - great, practical recipes and patterns: https://react-typescript-cheatsheet.netlify.app/
- TypeScript docs - official language guide: https://www.typescriptlang.org/docs/
- React docs - forwardRef, context, and hooks: https://reactjs.org/docs/
Conclusion
TypeScript dramatically improves the developer experience with React when used thoughtfully. Start by typing your components and hooks, leverage utility types to avoid duplication, and use generics and discriminated unions when interfaces become more complex. Enforce strict compiler options, add linting, and validate at runtime where necessary. The result will be safer code, clearer APIs, and faster refactors.
References
- React + TypeScript Cheatsheet: https://react-typescript-cheatsheet.netlify.app/
- TypeScript documentation: https://www.typescriptlang.org/docs/
- React documentation: https://reactjs.org/docs/