Jan 10, 20255 min read
TypeScript Best Practices for React Applications
- TypeScript
- React
- Best Practices
- Type Safety
TypeScript Best Practices for React Applications
After working with TypeScript in large-scale React applications at Deriv, I've compiled essential best practices that have improved code quality and developer experience.
Component Type Definitions
Functional Components with Props
Always explicitly type your component props:
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant,
size = 'md',
disabled = false,
onClick,
children
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
};
Generic Components
Create reusable components with generics:
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
}
export function Select<T>({
options,
value,
onChange,
getLabel,
getValue
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selected = options.find(
opt => getValue(opt).toString() === e.target.value
);
if (selected) onChange(selected);
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage
<Select
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(u) => u.name}
getValue={(u) => u.id}
/>
State Management Types
useState with Explicit Types
// Primitive types
const [count, setCount] = useState<number>(0);
const [name, setName] = useState<string>('');
// Complex types
interface User {
id: number;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
useReducer with Discriminated Unions
// Define state
interface State {
data: User[];
loading: boolean;
error: string | null;
}
// Define actions using discriminated unions
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string };
// Reducer with full type safety
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, data: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
// Usage
const [state, dispatch] = useReducer(reducer, {
data: [],
loading: false,
error: null
});
API Response Types
Type-Safe API Calls
// Define API response types
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
email: string;
}
// Type-safe fetch function
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Usage with error handling
async function loadUser(id: number) {
try {
const result = await fetchUser(id);
// result.data is typed as User
console.log(result.data.name);
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
}
}
Utility Types
Useful Built-in Utility Types
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Partial - make all properties optional
type UpdateUser = Partial<User>;
// Pick - select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// Omit - exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// Required - make all properties required
type RequiredUser = Required<Partial<User>>;
// Record - create object type with specific keys
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
// ReturnType - get function return type
function getUser() {
return { id: 1, name: 'John' };
}
type User = ReturnType<typeof getUser>;
Custom Hooks with Types
interface UseFormReturn<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
handleChange: (field: keyof T, value: T[keyof T]) => void;
handleSubmit: (callback: (values: T) => void) => void;
reset: () => void;
}
function useForm<T>(initialValues: T): UseFormReturn<T> {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const handleChange = (field: keyof T, value: T[keyof T]) => {
setValues(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (callback: (values: T) => void) => {
// Validation logic
callback(values);
};
const reset = () => {
setValues(initialValues);
setErrors({});
};
return { values, errors, handleChange, handleSubmit, reset };
}
// Usage
interface LoginForm {
email: string;
password: string;
}
const { values, handleChange, handleSubmit } = useForm<LoginForm>({
email: '',
password: ''
});
Event Handlers
Type-Safe Event Handlers
// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
// Input events
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// Click events
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget);
};
// Keyboard events
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// Handle enter
}
};
Best Practices Summary
1. Always Enable Strict Mode
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true
}
}
2. Use Const Assertions
const ROUTES = {
HOME: '/',
BLOG: '/blog',
CONTACT: '/contact'
} as const;
type Route = typeof ROUTES[keyof typeof ROUTES];
3. Avoid any Type
// Bad
const data: any = fetchData();
// Good
interface ApiData {
id: number;
value: string;
}
const data: ApiData = fetchData();
// When type is truly unknown
const data: unknown = fetchData();
if (isApiData(data)) {
// Type guard narrows unknown to ApiData
}
4. Use Type Guards
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj
);
}
const maybeUser: unknown = fetchData();
if (isUser(maybeUser)) {
console.log(maybeUser.name); // Type-safe!
}
Conclusion
TypeScript in React isn't just about adding types—it's about creating self-documenting, maintainable code that prevents bugs before they happen. These patterns have saved countless hours of debugging in production applications.
Key Takeaways
- Always explicitly type component props and state
- Use discriminated unions for complex state management
- Leverage utility types for transformations
- Write type-safe custom hooks
- Enable strict mode and avoid
any
Want to discuss TypeScript patterns? Let's connect!