TypeScript Best Practices for React Developers

TypeScript Best Practices for React Developers

TypeScript Best Practices for React Developers

TypeScript has become an essential tool for React developers, providing type safety, better IDE support, and improved code maintainability. In this guide, we’ll explore the best practices for using TypeScript effectively in React applications.

Component Type Definitions

Functional Components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React from 'react';

// Basic functional component
interface Props {
title: string;
count: number;
isVisible?: boolean; // Optional prop
}

const MyComponent: React.FC<Props> = ({ title, count, isVisible = true }) => {
return (
<div>
<h1>{title}</h1>
{isVisible && <p>Count: {count}</p>}
</div>
);
};

// Alternative syntax (preferred)
const MyComponent = ({ title, count, isVisible = true }: Props) => {
return (
<div>
<h1>{title}</h1>
{isVisible && <p>Count: {count}</p>}
</div>
);
};

Props with Children

1
2
3
4
5
6
7
8
9
10
11
12
13
interface ContainerProps {
className?: string;
children: React.ReactNode;
}

const Container = ({ className, children }: ContainerProps) => {
return <div className={className}>{children}</div>;
};

// For specific child types
interface ButtonGroupProps {
children: React.ReactElement<ButtonProps> | React.ReactElement<ButtonProps>[];
}

State Management with TypeScript

useState Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { useState } from 'react';

interface User {
id: number;
name: string;
email: string;
}

const UserProfile = () => {
// Type inference works well for primitives
const [loading, setLoading] = useState(false);

// Explicit typing for complex types
const [user, setUser] = useState<User | null>(null);

// Array state
const [users, setUsers] = useState<User[]>([]);

// Union types
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

return (
<div>
{loading && <p>Loading...</p>}
{user && <p>Welcome, {user.name}!</p>}
</div>
);
};

useReducer Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
interface State {
count: number;
error: string | null;
}

type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' };

const initialState: State = {
count: 0,
error: null,
};

const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1, error: null };
case 'DECREMENT':
return { ...state, count: state.count - 1, error: null };
case 'SET_ERROR':
return { ...state, error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
};

const Counter = () => {
const [state, dispatch] = useReducer(reducer, initialState);

return (
<div>
<p>Count: {state.count}</p>
{state.error && <p>Error: {state.error}</p>}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
</div>
);
};

Event Handlers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import React, { ChangeEvent, FormEvent, MouseEvent } from 'react';

interface FormData {
email: string;
password: string;
}

const LoginForm = () => {
const [formData, setFormData] = useState<FormData>({
email: '',
password: '',
});

const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Handle form submission
};

const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
// Handle button click
};

return (
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
/>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
/>
<button type="submit" onClick={handleButtonClick}>
Login
</button>
</form>
);
};

Custom Hooks with TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { useState, useEffect } from 'react';

// Generic custom hook
function useApi<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result: T = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};

fetchData();
}, [url]);

return { data, loading, error };
}

// Usage
interface Post {
id: number;
title: string;
body: string;
}

const BlogPost = ({ postId }: { postId: number }) => {
const { data: post, loading, error } = useApi<Post>(`/api/posts/${postId}`);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!post) return <div>Post not found</div>;

return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
};

Context API with TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

// State and action types
interface AuthState {
user: User | null;
isAuthenticated: boolean;
loading: boolean;
}

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 | undefined>(undefined);

// Reducer
const 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 const 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 authenticateUser(email, password);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE' });
}
};

const logout = () => {
dispatch({ type: 'LOGOUT' });
};

return (
<AuthContext.Provider value={{ state, login, logout }}>
{children}
</AuthContext.Provider>
);
};

// Custom hook to use auth context
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

Advanced Patterns

Generic Components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}

function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}

// Usage
const users: User[] = [/* ... */];
const posts: Post[] = [/* ... */];

<List
items={users}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => user.id}
/>

<List
items={posts}
renderItem={post => <span>{post.title}</span>}
keyExtractor={post => post.id}
/>

Conditional Props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type ButtonProps = {
children: React.ReactNode;
onClick: () => void;
} & (
| { variant: 'primary'; color?: never }
| { variant: 'secondary'; color: 'blue' | 'red' | 'green' }
);

const Button = ({ children, onClick, variant, ...props }: ButtonProps) => {
return (
<button
onClick={onClick}
className={`btn btn-${variant} ${
'color' in props ? `btn-${props.color}` : ''
}`}
>
{children}
</button>
);
};

// Usage
<Button variant="primary" onClick={() => {}}>Primary</Button>
<Button variant="secondary" color="blue" onClick={() => {}}>Secondary</Button>

Best Practices Summary

1. Use Strict TypeScript Configuration

1
2
3
4
5
6
7
8
9
10
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}

2. Prefer Type Inference

1
2
3
4
5
// ❌ Unnecessary explicit typing
const [count, setCount] = useState<number>(0);

// ✅ Let TypeScript infer
const [count, setCount] = useState(0);

3. Use Utility Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface User {
id: number;
name: string;
email: string;
password: string;
}

// Pick specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;

// Omit sensitive properties
type UserWithoutPassword = Omit<User, 'password'>;

// Make all properties optional
type PartialUser = Partial<User>;

// Make all properties required
type RequiredUser = Required<User>;

4. Create Reusable Type Definitions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// types/api.ts
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
limit: number;
total: number;
};
}

// Usage
const { data: users } = useApi<PaginatedResponse<User>>('/api/users');

Conclusion

TypeScript significantly improves the React development experience by providing type safety, better IDE support, and catching errors at compile time. By following these best practices, you’ll write more maintainable and robust React applications.

Key takeaways:

  • Use proper type definitions for props and state
  • Leverage TypeScript’s type inference when possible
  • Create reusable type definitions
  • Use utility types for common patterns
  • Implement proper error handling with typed contexts

Start implementing these patterns in your React projects and experience the benefits of type-safe development! 🎯

Author

Abdalkader

Posted on

2024-12-18

Updated on

2025-10-19

Licensed under

Comments