We earn commissions when you shop through the links below.
If you’ve been writing React for any length of time, you already know how quickly a codebase can spiral into a mess of implicit any types, missing prop definitions, and runtime errors that TypeScript should have caught. Learning the best TypeScript practices for React developers isn’t just about adding types — it’s about structuring your code so the compiler does the heavy lifting before your users find the bugs. This guide covers the patterns I actually use in production, not textbook theory.
1. Always Type Your Component Props Explicitly
The most common mistake I see is relying on inference or skipping prop types entirely. Define an interface for every component’s props, even simple ones. It pays dividends when you refactor.
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'ghost';
disabled?: boolean;
}
const Button = ({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) => {
return (
);
};
export default Button;Notice I’m using a plain interface rather than React.FC<ButtonProps>. The React.FC wrapper used to be the default recommendation, but it implicitly includes children in every component and hides the return type. Just type your props directly and let TypeScript infer the rest.
2. Use Discriminated Unions for Complex State
Boolean flags like isLoading, isError, and isSuccess all living on the same object is a footgun. You can have isLoading: true and isError: true simultaneously, which makes no semantic sense. Discriminated unions fix this at the type level.
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function UserProfile({ userId }: { userId: string }) {
const [state, setState] = React.useState>({ status: 'idle' });
// TypeScript now knows exactly what's available in each branch
if (state.status === 'loading') return ;
if (state.status === 'error') return ;
if (state.status === 'success') return ;
return ;
} This pattern eliminates entire categories of impossible states. When you narrow the union with a status check, TypeScript knows exactly which properties are available in that branch.
3. Type Your Custom Hooks Properly
Custom hooks that return tuples are a frequent source of type issues. If you return an array and TypeScript infers it as a union array instead of a tuple, consumers will get confusing errors.
// Bad — TypeScript infers (string | (() => void))[] which is wrong
function useToggle(initial: boolean) {
const [value, setValue] = React.useState(initial);
const toggle = () => setValue(v => !v);
return [value, toggle]; // ❌ inferred as (boolean | (() => void))[]
}
// Good — assert as const or use explicit return type
function useToggle(initial: boolean): [boolean, () => void] {
const [value, setValue] = React.useState(initial);
const toggle = () => setValue(v => !v);
return [value, toggle]; // ✅
}I prefer the explicit return type annotation over as const because it forces me to think about the contract the hook exposes. If the return type changes, TypeScript will immediately flag every consumer.
4. Leverage Generic Components
One of the most powerful best TypeScript practices for React developers is writing generic components. A reusable list or table component shouldn’t lose type safety.
interface ListProps {
items: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T) => React.ReactNode;
emptyMessage?: string;
}
function List({ items, keyExtractor, renderItem, emptyMessage = 'No items found' }: ListProps) {
if (items.length === 0) return {emptyMessage}
;
return (
{items.map(item => (
- {renderItem(item)}
))}
);
}
// Usage — TypeScript infers T as User
u.id}
renderItem={u => {u.name}}
/>
The generic parameter gets inferred from the items prop, so callers get full type safety in keyExtractor and renderItem without writing angle brackets at the call site.
5. Don’t Abuse any — Use unknown or Narrow Properly
Reaching for any is tempting when you’re dealing with third-party data or event handlers. But any silently disables type checking for everything downstream. Use unknown and narrow it explicitly.
// Typing event handlers correctly
function handleChange(e: React.ChangeEvent) {
console.log(e.target.value); // ✅ fully typed
}
// Handling API responses safely
async function fetchUser(id: string): Promise {
const res = await fetch(`/api/users/${id}`);
const json: unknown = await res.json();
// Use a validation library like zod here in production
if (!isUser(json)) throw new Error('Invalid user shape');
return json;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
} In production codebases, I pair this pattern with Zod for runtime validation. It gives you a TypeScript type and a runtime parser in one shot, which is exactly what you need when consuming external APIs.
6. Use the satisfies Operator for Config Objects
Introduced in TypeScript 4.9, satisfies lets you validate that an object matches a type without widening its inferred type. This is particularly handy for theme configs and route definitions in React apps.
type ThemeColors = {
primary: string;
secondary: string;
danger: string;
};
// Without satisfies — theme is typed as ThemeColors, losing literal types
const theme: ThemeColors = {
primary: '#6366f1',
secondary: '#8b5cf6',
danger: '#ef4444',
};
// With satisfies — TypeScript validates the shape AND preserves literal types
const theme = {
primary: '#6366f1',
secondary: '#8b5cf6',
danger: '#ef4444',
} satisfies ThemeColors;
// theme.primary is still '#6366f1' (literal), not just string7. Configure tsconfig.json for Maximum Safety
The compiler options you choose directly determine how useful TypeScript is. Here’s a minimal strict config I use on every React project:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}noUncheckedIndexedAccess is the one most developers skip, but it’s critical — it forces you to handle the case where an array index or object key doesn’t exist, which is a surprisingly common source of runtime errors in React components that render lists.
Tooling That Makes This Easier
Writing TypeScript well is much faster when your editor gives you instant feedback and intelligent completions. I use Cursor as my primary editor — its AI-assisted completions understand TypeScript generics and discriminated unions in ways that plain IntelliSense doesn’t, which speeds up exactly the patterns covered here.
If you want a more structured path through TypeScript and React together, Udemy has several courses that cover these patterns in depth with hands-on projects, which is useful if you’re onboarding junior developers or switching from a JavaScript-first background.
Putting It All Together
The best TypeScript practices for React developers aren’t about adding types to satisfy a linter — they’re about designing components so invalid states are literally unrepresentable, and so refactoring is safe by default. The patterns above compound on each other: explicit prop interfaces make generic components easy to write, discriminated unions make custom hooks predictable, and a tight tsconfig.json makes the compiler catch what you’d otherwise find in production.
Start with strict mode and explicit prop interfaces if you’re retrofitting an existing codebase. Add discriminated unions wherever you have boolean flag soup. Once those habits are in place, generics and the satisfies operator become natural extensions rather than advanced tricks.