TypeScript Best Practices for React Development
Essential TypeScript patterns and best practices for building robust React applications with better type safety and developer experience.
Nosgnoh
October 8, 2024
8 minute read
TypeScript Best Practices for React Development
TypeScript has become the de facto standard for modern React development. Let's explore essential patterns and best practices that will make your React applications more robust and maintainable.
Component Typing Fundamentals
Functional Component Types
import { FC, ReactNode } from 'react';
// Method 1: Using FC (Function Component)
const Button: FC<ButtonProps> = ({ children, onClick, variant = 'primary' }) => {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{children}
</button>
);
};
// Method 2: Explicit return type (preferred)
function Button({ children, onClick, variant = 'primary' }: ButtonProps): JSX.Element {
return (
<button className={`btn btn-${variant}`} onClick={onClick}>
{children}
</button>
);
}
interface ButtonProps {
children: ReactNode;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
}
Props with Generics
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Usage
const users = [
{ id: 1, name: 'John', email: 'john@example.com' },
{ id: 2, name: 'Jane', email: 'jane@example.com' }
];
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
)}
/>
Advanced Hook Typing
Custom Hook with Generic Types
import { useState, useEffect } from 'react';
interface UseApiResponse<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useApi<T>(url: string): UseApiResponse<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: T = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data: user, loading, error } = useApi<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Event Handler Typing
import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';
interface FormProps {
onSubmit: (data: FormData) => void;
}
interface FormData {
name: string;
email: string;
message: string;
}
function ContactForm({ onSubmit }: FormProps) {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: ''
});
const handleInputChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(formData);
};
const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked', e.currentTarget.name);
};
const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && e.ctrlKey) {
// Handle Ctrl+Enter
}
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
/>
<button type="submit" onClick={handleButtonClick}>
Submit
</button>
</form>
);
}
Context and State Management
Typed Context
import { createContext, useContext, ReactNode, useReducer } from 'react';
// State types
interface User {
id: number;
name: string;
email: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
loading: boolean;
}
// Action types
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_FAILURE' }
| { type: 'LOGOUT' };
// Context type
interface AuthContextType {
state: AuthState;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// Create context with default value
const AuthContext = createContext<AuthContextType | null>(null);
// Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loading: true };
case 'LOGIN_SUCCESS':
return {
user: action.payload,
isAuthenticated: true,
loading: false
};
case 'LOGIN_FAILURE':
return {
user: null,
isAuthenticated: false,
loading: false
};
case 'LOGOUT':
return {
user: null,
isAuthenticated: false,
loading: false
};
default:
return state;
}
}
// Provider component
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthenticated: false,
loading: false
});
const login = async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await authApi.login(email, password);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE' });
throw error;
}
};
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
return (
<AuthContext.Provider value={{ state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Custom hook with proper error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Advanced Type Patterns
Conditional Types and Utility Types
// Conditional rendering based on props
interface BaseProps {
title: string;
}
interface WithLink extends BaseProps {
href: string;
onClick?: never;
}
interface WithButton extends BaseProps {
onClick: () => void;
href?: never;
}
type CardProps = WithLink | WithButton;
function Card(props: CardProps) {
if (props.href) {
return (
<a href={props.href} className="card">
<h3>{props.title}</h3>
</a>
);
}
return (
<button onClick={props.onClick} className="card">
<h3>{props.title}</h3>
</button>
);
}
// Utility types for form handling
interface ApiUser {
id: number;
name: string;
email: string;
createdAt: string;
updatedAt: string;
}
// Create form type by picking only editable fields
type UserFormData = Pick<ApiUser, 'name' | 'email'>;
// Make all fields optional for partial updates
type UserUpdateData = Partial<UserFormData>;
// Omit sensitive fields from public interface
type PublicUser = Omit<ApiUser, 'createdAt' | 'updatedAt'>;
Generic Form Components
type FormField<T> = {
[K in keyof T]: {
value: T[K];
error?: string;
touched: boolean;
};
};
interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Partial<Record<keyof T, string>>;
onSubmit: (values: T) => Promise<void> | void;
}
function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit
}: UseFormOptions<T>) {
const [fields, setFields] = useState<FormField<T>>(
Object.keys(initialValues).reduce((acc, key) => {
acc[key as keyof T] = {
value: initialValues[key as keyof T],
touched: false
};
return acc;
}, {} as FormField<T>)
);
const updateField = <K extends keyof T>(name: K, value: T[K]) => {
setFields(prev => ({
...prev,
[name]: {
...prev[name],
value,
touched: true
}
}));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const values = Object.keys(fields).reduce((acc, key) => {
acc[key as keyof T] = fields[key as keyof T].value;
return acc;
}, {} as T);
if (validate) {
const errors = validate(values);
if (Object.keys(errors).length > 0) {
// Handle validation errors
return;
}
}
await onSubmit(values);
};
return {
fields,
updateField,
handleSubmit
};
}
Component Composition Patterns
Render Props with TypeScript
interface RenderPropExample<T> {
data: T[];
loading: boolean;
children: (props: { data: T[]; loading: boolean }) => ReactNode;
}
function DataProvider<T>({ data, loading, children }: RenderPropExample<T>) {
return <>{children({ data, loading })}</>;
}
// Higher-Order Component typing
interface WithLoadingProps {
loading: boolean;
}
function withLoading<P extends object>(
Component: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
return function WithLoadingComponent(props: P & WithLoadingProps) {
if (props.loading) {
return <div>Loading...</div>;
}
return <Component {...props} />;
};
}
// Usage
const UserListWithLoading = withLoading(UserList);
Best Practices Summary
1. Prefer Interfaces Over Types for Object Shapes
// Good
interface User {
id: number;
name: string;
}
// Use type for unions and computed types
type Status = 'loading' | 'success' | 'error';
type UserKeys = keyof User;
2. Use Strict TypeScript Configuration
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
3. Avoid any - Use Unknown Instead
// Bad
function processData(data: any) {
return data.someProperty;
}
// Good
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'someProperty' in data) {
return (data as { someProperty: any }).someProperty;
}
throw new Error('Invalid data structure');
}
4. Use Type Guards
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
typeof (obj as any).id === 'number' &&
typeof (obj as any).name === 'string'
);
}
function handleUserData(data: unknown) {
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.name);
}
}
Conclusion
TypeScript transforms React development by providing compile-time error checking, better IntelliSense, and improved refactoring capabilities. By following these patterns and best practices, you'll write more maintainable and robust React applications.
Remember: start strict, be explicit with types, and leverage TypeScript's powerful type system to catch errors before they reach production.
Tags
#typescript#react#types#interfaces#generics#best-practices
Related Posts
TypeScriptJavaScript+1
TypeScript Best Practices: Building Scalable Applications
March 10, 2024•8 minute read
Nosgnoh
Featured
Next.jsReact+1
Getting Started with Next.js 13: App Router Deep Dive
January 15, 2024•4 minute read
Nosgnoh
Featured
Next.jsReact+1
Getting Started with Next.js 13+ App Directory
December 15, 2024•3 minute read
Nosgnoh
All Posts
Share this post with your network