Understanding React's Core Concepts Through Examples
Erik Nguyen / November 4, 2024
Understanding React's Core Concepts Through Examples
In this comprehensive guide, we'll explore React's core concepts through practical examples. Each concept will be demonstrated with real-world scenarios to help solidify your understanding.
1. Components: The Building Blocks
Functional Components vs Class Components
While class components are still supported, functional components with hooks are now the recommended approach in modern React development.
// Modern Functional Component
interface UserProfileProps {
name: string;
role: string;
avatar: string;
}
const UserProfile = ({ name, role, avatar }: UserProfileProps) => {
return (
<div className="user-profile">
<img src={avatar} alt={name} />
<h2>{name}</h2>
<span>{role}</span>
</div>
);
};
// Legacy Class Component (for comparison)
class UserProfileClass extends React.Component<UserProfileProps> {
render() {
const { name, role, avatar } = this.props;
return (
<div className="user-profile">
<img src={avatar} alt={name} />
<h2>{name}</h2>
<span>{role}</span>
</div>
);
}
}
Component Composition
interface CardProps {
title: string;
children: React.ReactNode;
}
const Card = ({ title, children }: CardProps) => {
return (
<div className="card">
<div className="card-header">
<h3>{title}</h3>
</div>
<div className="card-content">
{children}
</div>
</div>
);
};
const UserCard = () => {
return (
<Card title="User Profile">
<UserProfile
name="John Doe"
role="Developer"
avatar="/avatar.jpg"
/>
</Card>
);
};
2. Props and PropTypes
Props Deep Dive
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
isLoading?: boolean;
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
const Button = ({
variant = 'primary',
size = 'medium',
isLoading = false,
disabled = false,
onClick,
children
}: ButtonProps) => {
const baseClasses = 'button';
const variantClasses = `button-${variant}`;
const sizeClasses = `button-${size}`;
return (
<button
className={`${baseClasses} ${variantClasses} ${sizeClasses}`}
onClick={onClick}
disabled={disabled || isLoading}
>
{isLoading ? <Spinner /> : children}
</button>
);
};
Props Drilling and Context
Prop drilling can make code harder to maintain. Consider using Context for deeply nested data passing.
// Creating a Theme Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);
// Theme Provider
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
// Using Context instead of prop drilling
const ThemedButton = () => {
const themeContext = React.useContext(ThemeContext);
if (!themeContext) throw new Error('Must be used within ThemeProvider');
const { theme, toggleTheme } = themeContext;
return (
<Button
variant={theme= 'light' ? 'primary' : 'secondary'}
onClick={toggleTheme}
>
Toggle Theme
</Button>
);
};
3. State Management
Local State with useState
interface Todo {
id: number;
text: string;
completed: boolean;
}
const TodoList = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [inputValue, setInputValue] = useState('');
const addTodo = () => {
if (!inputValue.trim()) return;
setTodos(prev => [
...prev,
{
id: Date.now(),
text: inputValue,
completed: false
}
]);
setInputValue('');
};
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
return (
<div>
<input
value={inputValue}
onChange={e=> setInputValue(e.target.value)}
/>
<button onClick={addTodo}>Add Todo</button>
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={()=> toggleTodo(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};
Complex State with useReducer
interface State {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
loading: boolean
}
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: State['filter'] }
| { type: 'SET_LOADING'; payload: boolean }
const todoReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload,
completed: false
}
]
}
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
}
case 'SET_FILTER':
return {
...state,
filter: action.payload
}
case 'SET_LOADING':
return {
...state,
loading: action.payload
}
default:
return state
}
}
4. Effects and Lifecycle
useEffect Patterns
const UserProfile = ({ userId }: { userId: string }) => {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch user data
useEffect(() => {
let isMounted = true;
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (isMounted) {
setUser(data);
}
} catch (err) {
if (isMounted) {
setError('Failed to fetch user');
}
}
};
fetchUser();
// Cleanup function
return () => {
isMounted = false;
};
}, [userId]);
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Loading...</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
Cleanup and Memory Leaks
Always clean up subscriptions, intervals, and event listeners to prevent memory leaks.
const Timer = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// Cleanup function
return () => clearInterval(interval);
}, []);
return <div>Count: {count}</div>;
};
5. Performance Optimization
Memoization with useMemo and useCallback
const ExpensiveComponent = ({ data, onItemClick }: {
data: number[];
onItemClick: (item: number) => void;
}) => {
// Memoize expensive calculations
const processedData = useMemo(() => {
return data.map(item => item * 2);
}, [data]);
// Memoize callbacks
const handleClick = useCallback((item: number) => {
onItemClick(item);
}, [onItemClick]);
return (
<ul>
{processedData.map(item => (
<li key={item} onClick={()=> handleClick(item)}>
{item}
</li>
))}
</ul>
);
};
React.memo for Component Memoization
interface ItemProps {
text: string;
onDelete: () => void;
}
const Item = memo(({ text, onDelete }: ItemProps) => {
return (
<div>
<span>{text}</span>
<button onClick={onDelete}>Delete</button>
</div>
);
});
Don't overuse memoization. Only apply it when you have identified a performance bottleneck.
Best Practices and Common Patterns
Custom Hooks
const useLocalStorage = <T>(key: string, initialValue: T) => {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(error)
return initialValue
}
})
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(error)
}
}
return [storedValue, setValue] as const
}
Error Boundaries
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error:', error);
console.error('Error Info:', errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Conclusion
Understanding these core concepts is crucial for building robust React applications. Remember:
- Components are the building blocks of your UI
- Props and state manage data flow
- Effects handle side effects
- Performance optimization should be applied strategically
- Custom hooks help with code reuse
- Error boundaries provide graceful error handling
Practice these concepts by building small, focused components before combining them into larger applications.