Chapter 13: React Components ⚛️
"Components are the building blocks of React applications. Mastering them will allow you to create complex interfaces from simple, reusable pieces."
React revolutionized web development by introducing a component-based architecture that makes building user interfaces more intuitive, maintainable, and efficient. In this chapter, we'll dive deep into React components, exploring how they work and how to use them effectively to build dynamic, interactive web applications.
<Component/> 🧩
At its core, a React component is a JavaScript function or class that returns a piece of UI. Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.
Functional Components
The modern approach to creating React components is using JavaScript functions:
function Welcome() {
return <h1>Hello, World!</h1>;
}
// Usage
<Welcome />
This simple component returns an <h1> element with the text "Hello, World!". When React encounters <Welcome /> in your code, it will render this component.
Class Components (Legacy)
Before React 16.8 introduced Hooks, class components were the primary way to use state and lifecycle methods:
import React from 'react';
class Welcome extends React.Component {
render() {
return <h1>Hello, World!</h1>;
}
}
// Usage
<Welcome />
While class components are still supported, functional components with Hooks are now the recommended approach for new code.
Component Composition
Components can be composed together to build complex UIs:
function App() {
return (
<div>
<Header />
<MainContent />
<Sidebar />
<Footer />
</div>
);
}
function Header() {
return <header>This is the header</header>;
}
function MainContent() {
return <main>This is the main content</main>;
}
function Sidebar() {
return <aside>This is the sidebar</aside>;
}
function Footer() {
return <footer>This is the footer</footer>;
}
Component Files
Each component typically lives in its own file. The conventional approach is:
- Create a file named after your component (using PascalCase)
- Define the component in that file
- Export the component
- Import it where needed
// Button.jsx
function Button() {
return <button>Click Me</button>;
}
export default Button;
// App.jsx
import Button from './Button';
function App() {
return (
<div>
<h1>My App</h1>
<Button />
</div>
);
}
Component Organization
As your app grows, organizing components becomes crucial. Common approaches include:
- By Feature: Group components related to the same feature
src/ ├── features/ │ ├── authentication/ │ │ ├── LoginForm.jsx │ │ └── SignupForm.jsx │ └── dashboard/ │ ├── Dashboard.jsx │ └── DashboardItem.jsx
- By Type: Group components by their role
src/ ├── components/ │ ├── layouts/ │ │ ├── Header.jsx │ │ └── Footer.jsx │ ├── forms/ │ │ ├── TextField.jsx │ │ └── Button.jsx │ └── ui/ │ ├── Card.jsx │ └── Modal.jsx
- Atomic Design: Organize by complexity level
src/ ├── components/ │ ├── atoms/ │ │ ├── Button.jsx │ │ └── Input.jsx │ ├── molecules/ │ │ ├── SearchBar.jsx │ │ └── FormField.jsx │ └── organisms/ │ ├── LoginForm.jsx │ └── Navigation.jsx
JSX 📝
JSX (JavaScript XML) is a syntax extension for JavaScript that looks similar to HTML but allows you to write React elements in a familiar markup style.
Basic Syntax
const element = <h1>Hello, JSX!</h1>;
This may look like HTML, but it's actually JSX that gets transformed into JavaScript. The equivalent without JSX would be:
const element = React.createElement('h1', null, 'Hello, JSX!');
Embedding Expressions
You can embed any JavaScript expression in JSX using curly braces:
const name = 'John';
const greeting = <h1>Hello, {name}!</h1>;
// Expressions can be complex
const item = {
id: 1,
name: 'Apple'
};
const element = <div>Item ID: {item.id}, Name: {item.name}</div>;
// You can also use functions
function formatName(user) {
return user.firstName + ' ' + user.lastName;
}
const user = { firstName: 'John', lastName: 'Doe' };
const greeting = <h1>Hello, {formatName(user)}!</h1>;
JSX Attributes
JSX uses camelCase property naming instead of HTML's attribute names:
// HTML: <div class="container">
const element = <div className="container">Hello</div>;
// HTML: <input type="text" disabled>
const input = <input type="text" disabled={true} />;
// Setting inline styles
const style = { fontSize: '14px', color: 'blue' };
const element = <div style={style}>Styled Text</div>;
// Or inline
const element = <div style={{ fontSize: '14px', color: 'blue' }}>Styled Text</div>;
JSX Represents Objects
When you write JSX, Babel compiles it down to React.createElement() calls:
// This JSX
const element = (
<h1 className="greeting">
Hello, world!
</h1>
);
// Compiles to this
const element = React.createElement(
'h1',
{ className: 'greeting' },
'Hello, world!'
);
These React.createElement() calls create "React elements," which are plain JavaScript objects describing what you want to see on the screen.
JSX Rules
- Single Root Element: JSX must return a single root element
// Wrong function Component() { return ( <h1>Title</h1> <p>Paragraph</p> ); } // Right - use a wrapper element or fragment function Component() { return ( <div> <h1>Title</h1> <p>Paragraph</p> </div> ); } // Also right - using fragment function Component() { return ( <> <h1>Title</h1> <p>Paragraph</p> </> ); }
- Close All Tags: JSX requires all tags to be closed
// Wrong in JSX (but valid in HTML) <input type="text"> // Right <input type="text" />
- camelCase Attributes: Use camelCase for attributes
// Wrong in JSX <div class="container" onclick="handleClick()"> // Right <div className="container" onClick={handleClick}>
Conditional Rendering in JSX
// Using ternary operator
function Greeting({ isLoggedIn }) {
return (
<div>
{isLoggedIn ? <h1>Welcome back!</h1> : <h1>Please sign in</h1>}
</div>
);
}
// Using logical && operator
function Mailbox({ unreadMessages }) {
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 &&
<p>You have {unreadMessages.length} unread messages.</p>
}
</div>
);
}
// Using if statements outside JSX
function Greeting({ isLoggedIn }) {
let greeting;
if (isLoggedIn) {
greeting = <h1>Welcome back!</h1>;
} else {
greeting = <h1>Please sign in</h1>;
}
return <div>{greeting}</div>;
}
Lists in JSX
function TodoList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
The key prop is critical when rendering lists. It helps React identify which items have changed, been added, or removed, and should be unique among siblings.
useState 🔄
The useState Hook lets you add state to functional components, allowing them to remember and update information between renders.
Basic Usage
import { useState } from 'react';
function Counter() {
// Declare a state variable named "count" with initial value 0
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Here, useState returns a pair: the current state value (count) and a function that lets you update it (setCount).
Multiple State Variables
function UserForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
return (
<form>
<input
value={firstName}
onChange={e => setFirstName(e.target.value)}
placeholder="First name"
/>
<input
value={lastName}
onChange={e => setLastName(e.target.value)}
placeholder="Last name"
/>
<input
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
type="email"
/>
</form>
);
}
State with Objects and Arrays
// Using objects
function ProfileForm() {
const [profile, setProfile] = useState({
name: '',
bio: '',
active: true
});
const updateName = (e) => {
// Using the spread operator to create a new object
setProfile({
...profile,
name: e.target.value
});
};
return (
<div>
<input
value={profile.name}
onChange={updateName}
placeholder="Name"
/>
{/* Other fields */}
</div>
);
}
// Using arrays
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
// Using the spread operator to create a new array
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
// Component JSX
}
Functional Updates
When the new state depends on the previous state, use the functional form of setState:
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
// This is safer when state updates depend on previous state
setCount(prevCount => prevCount + 1);
};
// This won't work correctly for multiple consecutive updates
const badIncrement = () => {
setCount(count + 1);
setCount(count + 1); // Still using the original count
};
// This works correctly for multiple consecutive updates
const goodIncrement = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={goodIncrement}>Increment Twice</button>
</div>
);
}
Lazy Initial State
If the initial state is expensive to compute, provide a function to useState:
// This runs on every render
const [state, setState] = useState(expensiveComputation());
// This runs only on the first render
const [state, setState] = useState(() => expensiveComputation());
useState Best Practices
- Keep State Minimal: Only include what you need in state
- Group Related State: Consider using a single object for related state
- Avoid Redundant State: Don't store computed values that can be derived from props or other state
- Use Immutable Updates: Always create new objects/arrays when updating state
- Lift Shared State Up: Place shared state in the nearest common ancestor
useEffect 🔄
The useEffect Hook lets you perform side effects in functional components, like data fetching, subscriptions, or manually changing the DOM.
Basic Usage
import { useState, useEffect } from 'react';
function DocumentTitle({ title }) {
useEffect(() => {
// Side effect: update the document title
document.title = title;
}, [title]); // Only re-run when title changes
return <h1>{title}</h1>;
}
The function passed to useEffect will run after the render is committed to the screen. The second argument (the dependency array) controls when the effect runs.
Dependency Array
// Runs after every render
useEffect(() => {
console.log('Component rendered');
});
// Runs only on the first render (similar to componentDidMount)
useEffect(() => {
console.log('Component mounted');
}, []);
// Runs when count changes
useEffect(() => {
console.log('Count changed to', count);
}, [count]);
// Runs when count OR name changes
useEffect(() => {
console.log('Count or name changed');
}, [count, name]);
Cleanup Function
Effects sometimes create resources that need to be cleaned up before the component leaves the screen. Return a function from your effect to handle cleanup:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
// Subscribe to chat room
const connection = createConnection(roomId);
connection.connect();
connection.onMessage(message => {
setMessages(prev => [...prev, message]);
});
// Cleanup function that runs before the next effect or unmount
return () => {
connection.disconnect();
};
}, [roomId]);
return (
<div>
<h1>Room: {roomId}</h1>
<ul>
{messages.map(message => (
<li key={message.id}>{message.text}</li>
))}
</ul>
</div>
);
}
Common useEffect Patterns
- Data Fetching:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset state when userId changes
setLoading(true);
setError(null);
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
- Subscriptions:
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
// Add event listener
window.addEventListener('resize', handleResize);
// Cleanup: remove event listener
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array means run once on mount
return <div>Window width: {width}px</div>;
}
- DOM Manipulation:
function ScrollToTop() {
useEffect(() => {
window.scrollTo(0, 0);
}, []); // Run once when component mounts
return null;
}
useEffect Best Practices
- Keep Effects Focused: Each effect should do one thing
- Don't Overuse Effects: Consider if you actually need an effect
- Include All Dependencies: Always include all values from the component scope that change over time
- Use Multiple Effects: Split unrelated logic into different effects
- Handle Race Conditions: For data fetching, handle cases where the component unmounts before the fetch completes
- Use Cleanup Functions: Always clean up subscriptions, timers, and other resources
props 🔄
Props (short for "properties") are the mechanism for passing data from parent to child components in React.
Basic Props
// Parent component
function App() {
return <Greeting name="John" age={30} isAdmin={true} />;
}
// Child component
function Greeting(props) {
return (
<div>
<h1>Hello, {props.name}!</h1>
<p>You are {props.age} years old.</p>
{props.isAdmin && <p>You have admin privileges.</p>}
</div>
);
}
Props Destructuring
For cleaner code, you can destructure props:
function Greeting({ name, age, isAdmin }) {
return (
<div>
<h1>Hello, {name}!</h1>
<p>You are {age} years old.</p>
{isAdmin && <p>You have admin privileges.</p>}
</div>
);
}
Default Props
You can provide default values for props:
// Using destructuring with defaults
function Greeting({ name = 'Guest', age = 0, isAdmin = false }) {
return (
<div>
<h1>Hello, {name}!</h1>
<p>You are {age} years old.</p>
{isAdmin && <p>You have admin privileges.</p>}
</div>
);
}
// Or using static defaultProps
function Greeting(props) {
return (
<div>
<h1>Hello, {props.name}!</h1>
<p>You are {props.age} years old.</p>
{props.isAdmin && <p>You have admin privileges.</p>}
</div>
);
}
Greeting.defaultProps = {
name: 'Guest',
age: 0,
isAdmin: false
};
Passing Props
Props can be passed in various ways:
// Passing literal values
<Button text="Click me" size="large" enabled={true} />
// Spreading an object as props
const buttonProps = {
text: "Click me",
size: "large",
enabled: true
};
<Button {...buttonProps} />
// Overriding spread props
<Button {...buttonProps} size="small" />
Props Types (Type Checking)
For runtime type checking, you can use PropTypes:
import PropTypes from 'prop-types';
function User({ name, age, email }) {
return (
<div>
<h1>{name}</h1>
<p>Age: {age}</p>
<p>Email: {email}</p>
</div>
);
}
User.propTypes = {
name: PropTypes.string.isRequired,
age: PropTypes.number,
email: PropTypes.string.isRequired
};
For TypeScript projects, you can use interfaces or types:
interface UserProps {
name: string;
age?: number;
email: string;
}
function User({ name, age, email }: UserProps) {
return (
<div>
<h1>{name}</h1>
<p>Age: {age || 'Unknown'}</p>
<p>Email: {email}</p>
</div>
);
}
Prop Naming Conventions
- Use camelCase for prop names (e.g.,
backgroundColornotbackground-color)
- Boolean props should have names that imply a true/false value (e.g.,
isEnabled,hasError)
- Use descriptive names that indicate what the prop is for
Props Best Practices
- Keep Components Pure: Treat props as read-only
- Minimize Prop Drilling: Avoid passing props through many levels of components
- Use Composition: Instead of complex props, consider composition with children
- Validate Props: Use PropTypes or TypeScript
- Document Props: Add comments or documentation for each prop
- Consistent Naming: Follow a consistent naming convention
children 👨👩👧👦
The children prop is a special prop that allows you to pass components or elements as children to another component.
Basic Usage
function Card({ title, children }) {
return (
<div className="card">
<div className="card-header">
<h2>{title}</h2>
</div>
<div className="card-body">
{children}
</div>
</div>
);
}
// Usage
function App() {
return (
<Card title="Welcome">
<p>This is my card content.</p>
<button>Click me</button>
</Card>
);
}
In this example, the <p> and <button> elements become the children prop of the Card component.
Multiple Children
function Layout({ children }) {
return (
<div className="layout">
<header>Header</header>
<main>{children}</main>
<footer>Footer</footer>
</div>
);
}
// Usage
function App() {
return (
<Layout>
<h1>Page Title</h1>
<p>First paragraph</p>
<p>Second paragraph</p>
</Layout>
);
}
Children as Function (Render Props)
A powerful pattern is to make children a function:
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
const json = await response.json();
setData(json);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchData();
}, [url]);
return children({ data, loading, error });
}
// Usage
function App() {
return (
<DataFetcher url="/api/users">
{({ data, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}}
</DataFetcher>
);
}
React.Children Utilities
React provides utilities for working with children:
import React from 'react';
function ChildCounter({ children }) {
const childCount = React.Children.count(children);
return (
<div>
<p>There are {childCount} children.</p>
<div>{children}</div>
</div>
);
}
// Map and transform children
function WrappedList({ children }) {
const modifiedChildren = React.Children.map(children, child => {
return <li>{child}</li>;
});
return <ul>{modifiedChildren}</ul>;
}
// Usage
function App() {
return (
<WrappedList>
<span>Item 1</span>
<span>Item 2</span>
<span>Item 3</span>
</WrappedList>
);
}
Restricting Children
You might want to enforce specific types of children:
function TabGroup({ children }) {
// Validate that children are Tab components
React.Children.forEach(children, child => {
if (child.type.name !== 'Tab') {
throw new Error('TabGroup children should be Tab components');
}
});
return <div className="tab-group">{children}</div>;
}
function Tab({ label, children }) {
return (
<div className="tab">
<div className="tab-label">{label}</div>
<div className="tab-content">{children}</div>
</div>
);
}
// Usage
function App() {
return (
<TabGroup>
<Tab label="Tab 1">Content for tab 1</Tab>
<Tab label="Tab 2">Content for tab 2</Tab>
</TabGroup>
);
}
Children Best Practices
- Use for Component Composition: Use children to create reusable wrappers
- Avoid Manipulating Children: Children should generally be rendered as passed
- Document Children Requirements: Clearly specify what types of children are expected
- Provide Defaults: Consider providing default children if none are passed
- Consider Alternatives: For complex cases, named props might be clearer than children
Server/Client Component 🖥️↔️📱
In Next.js 13+, React components are categorized as either Server Components or Client Components, which determines where they render and what features they can use.
Server Components (Default)
Server Components are rendered on the server and sent to the client as HTML. They're the default in Next.js App Router.
Characteristics of Server Components
- No Client-Side JavaScript: Server Components don't send any JavaScript to the browser
- Direct Backend Access: Can directly access backend resources (databases, APIs)
- No React Hooks: Can't use useState, useEffect, or other Hooks
- No Browser APIs: Can't access window, document, etc.
- Always Secure: Can safely contain sensitive information
// app/products/page.jsx - Server Component
import { getProducts } from '@/lib/products';
export default async function ProductsPage() {
// This code runs on the server
const products = await getProducts();
return (
<div>
<h1>Products</h1>
<div className="product-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
))}
</div>
</div>
);
}
Client Components
Client Components are hydrated in the browser and can use client-side features like hooks. To create a Client Component, add the "use client" directive at the top of the file.
Characteristics of Client Components
- Interactive: Can use state, effects, and event handlers
- Browser API Access: Can access window, document, etc.
- React Hooks: Can use all React hooks
- Client-Side Logic: Can execute JavaScript in the browser
- User Interactions: Can respond to clicks, inputs, etc.
"use client";
// components/LikeButton.jsx - Client Component
import { useState } from 'react';
export default function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const handleLike = () => {
setLikes(likes + 1);
};
return (
<button onClick={handleLike}>
Like ({likes})
</button>
);
}
Mixing Server and Client Components
You can use Client Components inside Server Components (but not vice versa):
// app/products/[id]/page.jsx - Server Component
import { getProduct } from '@/lib/products';
import LikeButton from '@/components/LikeButton'; // Client Component
export default async function ProductPage({ params }) {
// Server-side data fetching
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
{/* Client Component used within Server Component */}
<LikeButton initialLikes={product.likes} />
</div>
);
}
Component Patterns in Next.js App Router
- Server Component with Client Component Children:
// app/dashboard/page.jsx - Server Component
import { getDashboardData } from '@/lib/data';
import DashboardMetrics from '@/components/DashboardMetrics'; // Client Component
import DashboardChart from '@/components/DashboardChart'; // Client Component
export default async function DashboardPage() {
const data = await getDashboardData();
return (
<div>
<h1>Dashboard</h1>
<DashboardMetrics metrics={data.metrics} />
<DashboardChart chartData={data.chartData} />
</div>
);
}
- Client Component Boundary:
"use client";
// components/InteractiveSection.jsx - Client Component
import { useState } from 'react';
import ServerContent from './ServerContent'; // Server Component
export default function InteractiveSection({ initialData }) {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow(!show)}>
{show ? 'Hide Content' : 'Show Content'}
</button>
{show && <ServerContent data={initialData} />}
</div>
);
}
- Server Component Exporting Server Actions:
// app/actions.js - Server Component
"use server";
import { revalidatePath } from 'next/cache';
import { saveProduct } from '@/lib/products';
export async function createProduct(formData) {
const product = {
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description')
};
await saveProduct(product);
revalidatePath('/products');
return { success: true };
}
When to Use Each
Use Server Components for:
- Data fetching
- Access to backend resources
- Sensitive operations (API keys, tokens)
- SEO-critical content
- Static or infrequently updated UI
- Large dependencies that shouldn't be sent to the client
Use Client Components for:
- Interactive UI elements
- Event listeners (onClick, onChange, etc.)
- Hooks (useState, useEffect, etc.)
- Browser API access
- Client-side libraries that use React context
- Components that need to maintain state across renders
Best Practices for Server and Client Components
- Push Interactivity Down the Tree: Keep as much of your UI in Server Components as possible, and make specific interactive pieces Client Components
- Colocate Related Logic: Keep data fetching close to where the data is used
- Share Data Between Components: Use React props to pass data from Server to Client Components
- Avoid Prop Drilling: For deep component trees, consider using a Provider pattern in a Client Component boundary
- Streaming and Suspense: Use Suspense boundaries to progressively render complex pages
// app/dashboard/page.jsx
import { Suspense } from 'react';
import DashboardHeader from '@/components/DashboardHeader';
import DashboardMetrics from '@/components/DashboardMetrics';
import DashboardCharts from '@/components/DashboardCharts';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function DashboardPage() {
return (
<div className="dashboard">
<DashboardHeader />
<Suspense fallback={<LoadingSpinner section="metrics" />}>
<DashboardMetrics />
</Suspense>
<Suspense fallback={<LoadingSpinner section="charts" />}>
<DashboardCharts />
</Suspense>
</div>
);
}
onClick() 🖱️
Event handling is a fundamental part of interactive React applications. The onClick event handler is one of the most common, but React supports a wide range of events.
Basic onClick Handler
function Button() {
const handleClick = () => {
console.log('Button clicked!');
};
return (
<button onClick={handleClick}>
Click Me
</button>
);
}
You can also define the handler inline:
function Button() {
return (
<button onClick={() => console.log('Button clicked!')}>
Click Me
</button>
);
}
Passing Parameters to Event Handlers
function ItemList({ items }) {
const handleItemClick = (item) => {
console.log('Item clicked:', item);
};
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => handleItemClick(item)}>
{item.name}
</li>
))}
</ul>
);
}
Note that we're using an arrow function to wrap the handler call. Without this, the function would be called immediately during rendering rather than when the click occurs.
The Event Object
React event handlers receive a synthetic event object that wraps the native browser event:
function Form() {
const handleSubmit = (event) => {
// Prevent the default form submission
event.preventDefault();
// Access form values
const formData = new FormData(event.target);
const name = formData.get('name');
console.log('Form submitted with name:', name);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="name" />
<button type="submit">Submit</button>
</form>
);
}
Combining with State
The most common pattern is to combine event handlers with state:
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
Common React Events
React supports many event types:
// Mouse events
<button onClick={handleClick}>Click</button>
<div onMouseEnter={handleMouseEnter}>Hover</div>
<div onMouseLeave={handleMouseLeave}>Hover Out</div>
<div onDoubleClick={handleDoubleClick}>Double Click</div>
// Form events
<form onSubmit={handleSubmit}>...</form>
<input onChange={handleChange} />
<input onFocus={handleFocus} />
<input onBlur={handleBlur} />
// Keyboard events
<input onKeyDown={handleKeyDown} />
<input onKeyUp={handleKeyUp} />
<input onKeyPress={handleKeyPress} /> // Deprecated in favor of onKeyDown
// Drag and drop events
<div onDragStart={handleDragStart} draggable="true">Drag Me</div>
<div onDrop={handleDrop} onDragOver={handleDragOver}>Drop Zone</div>
// Touch events
<div onTouchStart={handleTouchStart} />
<div onTouchMove={handleTouchMove} />
<div onTouchEnd={handleTouchEnd} />
Event Propagation
React events propagate like native browser events:
function Parent() {
const handleParentClick = () => {
console.log('Parent clicked');
};
const handleChildClick = (e) => {
console.log('Child clicked');
// Stop propagation to parent
e.stopPropagation();
};
return (
<div onClick={handleParentClick} style={{ padding: '20px', background: 'lightgray' }}>
Parent
<button onClick={handleChildClick} style={{ margin: '10px' }}>
Child
</button>
</div>
);
}
Synthetic Events vs. Native Events
React uses SyntheticEvent for cross-browser compatibility:
function Button() {
const handleClick = (e) => {
// React's synthetic event
console.log('Synthetic event type:', e.type);
// Access the native browser event
const nativeEvent = e.nativeEvent;
console.log('Native event type:', nativeEvent.type);
};
return <button onClick={handleClick}>Click Me</button>;
}
Event Delegation
React uses event delegation internally, attaching many event listeners at the root level rather than on each element:
function TodoList({ todos }) {
const handleItemClick = (todoId) => {
console.log('Todo clicked:', todoId);
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => handleItemClick(todo.id)}>
{todo.text}
</li>
))}
</ul>
);
}
Even though it looks like we're adding a separate onClick to each list item, React actually uses a single event listener at the root to handle all clicks, determining which callback to invoke based on the event's target.
Best Practices for Event Handling
- Keep Handlers Small: Move complex logic out of event handlers
- Debounce or Throttle: For frequent events like scrolling or resizing
- Memoize Handlers: Use useCallback for handlers passed to child components
- Name Handlers Consistently: Use a convention like
handle[Event]oron[Event]
- Accessibility: Ensure keyboard accessibility alongside click handlers
// Memoizing handlers with useCallback
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleChange = useCallback((e) => {
setQuery(e.target.value);
}, []);
const handleSubmit = useCallback((e) => {
e.preventDefault();
onSearch(query);
}, [query, onSearch]);
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={handleChange}
aria-label="Search"
/>
<button type="submit">Search</button>
</form>
);
}
Advanced React Component Patterns 🚀
Now that we've covered the fundamentals, let's explore some advanced component patterns used in professional React applications.
1. Compound Components
Compound components are a pattern where you have a parent component that manages the internal state, and child components that consume that state:
function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
// Clone children and inject props
const tabs = React.Children.map(children, (child, index) => {
return React.cloneElement(child, {
isActive: index === activeIndex,
onActivate: () => setActiveIndex(index),
});
});
return <div className="tabs">{tabs}</div>;
}
function Tab({ isActive, onActivate, children }) {
return (
<div
className={`tab ${isActive ? 'active' : ''}`}
onClick={onActivate}
>
{children}
</div>
);
}
// Usage
function App() {
return (
<Tabs>
<Tab>Tab 1 Content</Tab>
<Tab>Tab 2 Content</Tab>
<Tab>Tab 3 Content</Tab>
</Tabs>
);
}
2. Render Props
Render props is a pattern where a component receives a function as a prop that returns React elements:
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
setPosition({
x: event.clientX,
y: event.clientY
});
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
// Call the render prop function with the state
return render(position);
}
// Usage
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
<h1>Mouse Position</h1>
<p>X: {x}, Y: {y}</p>
</div>
)}
/>
);
}
3. Custom Hooks
Extract reusable logic into custom hooks:
// Custom hook
function useLocalStorage(key, initialValue) {
// State to store our value
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log(error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage.
const setValue = (value) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
// Usage
function App() {
const [name, setName] = useLocalStorage('name', 'Guest');
return (
<div>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
);
}
4. Context + Reducer
Combine Context API with useReducer for complex state management:
// Create context
const TodoContext = React.createContext();
// Define initial state
const initialState = {
todos: [],
loading: false,
error: null
};
// Define reducer
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, action.payload]
};
case 'REMOVE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
default:
return state;
}
}
// Create provider
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
const addTodo = (text) => {
dispatch({
type: 'ADD_TODO',
payload: { id: Date.now(), text, completed: false }
});
};
const removeTodo = (id) => {
dispatch({ type: 'REMOVE_TODO', payload: id });
};
const toggleTodo = (id) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
const value = {
todos: state.todos,
loading: state.loading,
error: state.error,
addTodo,
removeTodo,
toggleTodo
};
return (
<TodoContext.Provider value={value}>
{children}
</TodoContext.Provider>
);
}
// Custom hook to use the context
function useTodos() {
const context = useContext(TodoContext);
if (context === undefined) {
throw new Error('useTodos must be used within a TodoProvider');
}
return context;
}
// Usage
function TodoList() {
const { todos, toggleTodo, removeTodo } = useTodos();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>X</button>
</li>
))}
</ul>
);
}
function AddTodo() {
const { addTodo } = useTodos();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
addTodo(text);
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Add todo"
/>
<button type="submit">Add</button>
</form>
);
}
function App() {
return (
<TodoProvider>
<h1>Todo List</h1>
<AddTodo />
<TodoList />
</TodoProvider>
);
}
5. HOC (Higher-Order Component)
A higher-order component is a function that takes a component and returns a new component with additional props or behavior:
// HOC that adds loading state
function withLoading(Component) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <div>Loading...</div>;
}
return <Component {...props} />;
};
}
// Base component
function UserList({ users }) {
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// Enhanced component
const UserListWithLoading = withLoading(UserList);
// Usage
function App() {
const [isLoading, setIsLoading] = useState(true);
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then(data => {
setUsers(data);
setIsLoading(false);
});
}, []);
return <UserListWithLoading isLoading={isLoading} users={users} />;
}
Best Practices for React Components 🏆
Component Structure
- Keep Components Focused: Each component should do one thing well
- Extract Reusable Logic: Move complex logic into custom hooks
- Follow the Single Responsibility Principle: Split large components into smaller ones
- Consistent Naming: Use PascalCase for components and camelCase for instances
Performance Optimization
- Memoize Components: Use React.memo for pure functional components
- Avoid Unnecessary Renders: Use useMemo and useCallback for expensive calculations and event handlers
- Virtualize Long Lists: Use libraries like react-window for long lists
- Code Splitting: Use dynamic imports to split your bundle
// Memoized component
const ExpensiveComponent = React.memo(function ExpensiveComponent({ value }) {
// Only re-renders if value changes
return <div>{value}</div>;
});
// useMemo for expensive calculations
function SearchResults({ items, query }) {
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
}, [items, query]);
return (
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
State Management
- Lift State Up: When multiple components need the same state, move it to their closest common ancestor
- Keep State as Local as Possible: Only lift state when necessary
- Use Context Sparingly: Context is powerful but can make components less reusable
- Consider Global State Libraries: For complex apps, consider libraries like Redux, Recoil, or Zustand
React Antipatterns to Avoid
- Mutating State: Never modify state directly; always use setState or state updater functions
- Putting Everything in One Component: Break down large components
- Prop Drilling: Avoid passing props through many levels of components
- Using Indexes as Keys: Use stable, unique IDs instead of array indexes as keys
- Ignoring Component Lifecycle: Clean up side effects to prevent memory leaks
Practice Exercises 🏋️
- Building a Toggle Component:
- Create a reusable Toggle component that manages its own state
- The component should accept an
onChangeprop for external state updates
- Add a
defaultOnprop to control the initial state
- Style the toggle to look like a switch
- Data Fetching Component:
- Create a component that fetches data from an API
- Show loading and error states
- Implement a retry mechanism
- Use both Server and Client Components (if using Next.js)
- Form Component:
- Build a form with validation
- Create reusable form field components
- Implement controlled inputs with React state
- Add error messages for invalid inputs
- Advanced State Management:
- Implement a shopping cart using Context API and useReducer
- Add features like adding items, removing items, updating quantities
- Calculate totals and apply discounts
- Persist cart to localStorage
Pro Tip: When building components, think about the API (props) first. A well-designed component API makes it intuitive for other developers to use your components and reduces the need for documentation.
What you've learned:
- How to create and structure React components
- Writing JSX and understanding its syntax rules
- Managing state with useState and handling side effects with useEffect
- Passing data between components with props and children
- Distinguishing between Server and Client Components in Next.js
- Handling user interactions with event handlers like onClick
- Advanced component patterns for building scalable applications
Coming up next: In Chapter 14, we'll explore Next.js fundamentals, going deeper into routing, data fetching, and optimizations specific to the Next.js framework.

