Type Safety in React: Tips and Tricks
I shipped my first React + TypeScript component with any types everywhere. "I'll fix them later," I told myself. Later never came, and we got runtime errors that TypeScript should've caught.
Two years and many bugs later, I've developed patterns that actually work. Here's what I wish I'd known from day one.
Why TypeScript + React in 2025
React 19 has doubled down on type safety. Server Components, Actions, the new use hook—they all benefit massively from TypeScript. But the tooling has also gotten way better.
With AI assistants like Claude or Cursor's AI, I'll often paste a component and ask: "Add proper TypeScript types to this React component." The AI generates types, I review them, adjust as needed. What used to take 30 minutes now takes 5.
But you still need to know what good types look like. Let's dive in.
Component Props: Get This Right First
The Basic Pattern I Use
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary";
disabled?: boolean;
}
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
}
Why interface over type? Better error messages. When TypeScript yells at you about props, interface errors are more readable.
React.FC: Just Don't
There's endless debate about React.FC. Here's my take after using it for 2 years: don't use it.
// ✗ React.FC has problems
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
// ✓ Plain function is better
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
Why I stopped using React.FC:
- Implicitly adds
children(confusing when you don't want children) - Doesn't work with generics
- More verbose with no benefit
The React team is moving away from recommending it. So should you.
Extending HTML Elements
Don't redefine standard HTML attributes—extend them:
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant: "primary" | "secondary";
loading?: boolean;
}
function Button({ variant, loading, ...props }: ButtonProps) {
return (
<button
{...props}
className={`btn btn-${variant} ${props.className || ""}`}
disabled={loading || props.disabled}
>
{loading ? "Loading..." : props.children}
</button>
);
}
// Now all these work automatically:
<Button
variant="primary"
onClick={() => {}}
aria-label="Submit"
data-testid="submit-btn"
style={{ margin: 10 }}
/>
This pattern saved me from typing out dozens of props manually. Extend the native element, add your custom props, done.
AI prompt: "Create a TypeScript interface for a React button that extends native button props and adds a variant prop."
Event Handlers: Stop Using any
The Types That Matter
// Click events
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
console.log(e.currentTarget); // HTMLButtonElement
}
// Input change
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
// Form submit
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// Handle form
}
// Keyboard events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
// Handle enter
}
}
The pattern: React.EventType<HTMLElementType>
I keep this cheat sheet bookmarked because I always forget the exact types. But autocomplete in VS Code/Cursor usually gets me there.
Inline vs Extracted Handlers
TypeScript infers types for inline handlers:
// Inline - type inferred automatically
<button onClick={(e) => {
// e is React.MouseEvent<HTMLButtonElement>
console.log(e.currentTarget);
}}>
Click
</button>
// Extracted - need explicit type
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget);
};
<button onClick={handleClick}>Click</button>
For simple handlers, inline is fine. For complex ones, extract and type explicitly.
Hooks: Type-Safe State Management
useState: When to Be Explicit
TypeScript usually infers useState types:
// Inferred as number
const [count, setCount] = useState(0);
// Inferred as string
const [name, setName] = useState("");
But for union types or nullable state, be explicit:
// ✗ TypeScript infers `null` and won't let you assign User
const [user, setUser] = useState(null);
// ✓ Explicit union type
const [user, setUser] = useState<User | null>(null);
// Now this works
setUser({ id: 1, name: "John" });
// And TypeScript forces you to check null
if (user) {
console.log(user.name); // Safe
}
Common mistake: Forgetting the generic when initial state is null or undefined.
useRef: DOM Elements vs Mutable Values
Two patterns, different types:
// DOM element ref - starts as null
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // Need optional chaining
}, []);
return <input ref={inputRef} />;
// Mutable value ref - never null
const countRef = useRef<number>(0);
useEffect(() => {
countRef.current += 1; // No optional chaining needed
});
DOM refs start as null until React attaches them. Mutable refs start with your provided value.
useReducer: Type the Actions
interface State {
count: number;
error: string | null;
}
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "SET_ERROR"; error: string }
| { type: "RESET" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "DECREMENT":
return { ...state, count: state.count - 1 };
case "SET_ERROR":
// TypeScript knows action.error exists
return { ...state, error: action.error };
case "RESET":
return { count: 0, error: null };
default:
// Exhaustiveness check
const _exhaustive: never = action;
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
</div>
);
}
Discriminated unions for actions are bulletproof. TypeScript ensures you handle every case and pass the right payload.
Related reading: See my post on TypeScript discriminated unions for more on this pattern.
Context: Type-Safe Global State
The Pattern That Works
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => {
setTheme(t => t === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook with runtime check
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}
Why undefined initially? You can't provide a meaningful default for most context. Making it undefined forces consumers to use the provider.
The custom hook pattern ensures you never forget the provider and removes undefined from the type.
React 19 Features (2025 Update)
Typing Server Actions
// Server action
async function updateUser(formData: FormData): Promise<{ success: boolean; error?: string }> {
"use server";
const name = formData.get("name") as string;
// ... update logic
return { success: true };
}
// Client component
function UserForm() {
const [state, formAction] = useActionState(updateUser, { success: false });
return (
<form action={formAction}>
<input name="name" />
<button type="submit">Update</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
React 19's server actions work beautifully with TypeScript. The types flow from server to client automatically.
The use Hook
// Typed promise
const userPromise: Promise<User> = fetchUser(id);
function UserProfile() {
const user = use(userPromise); // Type is User, not Promise<User>
return <div>{user.name}</div>;
}
use() unwraps promises at the type level. No more Awaited<> gymnastics.
Using AI for React TypeScript
My workflow in 2025:
- Write the component: Get it working without perfect types
- Let TypeScript complain: See what's actually broken
- Ask AI for help: "Fix the TypeScript errors in this React component"
- Review the changes: Understand what types it added and why
- Refactor: Simplify complex types, extract interfaces
Prompts I use:
- "Add TypeScript types to this React component: [paste component]"
- "How do I type this custom hook that returns multiple values?"
- "Type this context provider with the custom hook pattern"
- "Fix this TypeScript error in my React component: [paste error]"
The AI is great at boilerplate types. I focus on architecture and business logic.
Patterns to Avoid
Over-typing Simple Components
// ✗ Overkill for a simple div
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
// ✓ Just use children directly
function Container({ children }: { children: React.ReactNode }) {
return <div className="container">{children}</div>;
}
Don't create elaborate type hierarchies for components with 2 props.
Generic Components Without Constraints
// ✗ Too generic - no type safety
function List<T>({ items }: { items: T[] }) {
return items.map((item, i) => <div key={i}>{item}</div>);
}
// ✓ Add constraints
function List<T extends { id: string | number }>({ items }: { items: T[] }) {
return items.map(item => <div key={item.id}>{item.id}</div>);
}
Unconstrained generics lose type safety. Add constraints to preserve it.
Tools That Help
In 2025, the ecosystem is mature:
- VS Code / Cursor: Inline errors, autocomplete, refactoring
- TypeScript 5.x: Better JSX type checking
- ESLint: React + TypeScript rules
- ts-reset: Better React type defaults
- AI assistants: Type generation and error fixing
I also keep the React TypeScript Cheatsheet bookmarked for quick reference.
Wrapping Up
React + TypeScript catches bugs before they reach users. The patterns that matter:
- Plain functions over React.FC
- Extend HTML element props instead of redefining them
- Explicit types for useState with unions
- Discriminated unions for useReducer actions
- Context with custom hooks for type safety
And remember: AI assistants are fantastic at generating React TypeScript boilerplate. Use them. Focus your energy on component architecture and business logic.
Building large apps? Check out my posts on advanced TypeScript patterns and async/await in React for scaling patterns.
Working with Next.js? My guide on building performant Next.js apps covers server component typing and performance patterns.