React Essentials: From Components to Routing
React has revolutionized how we build user interfaces, but why was it created in the first place? What problems does it solve that traditional JavaScript couldn't handle? In this comprehensive guide, we'll journey from React's origins to advanced concepts like Fiber architecture, Hooks, and state management.
The Problem React Solves: Facebook's Phantom Message Issue
Before diving into React's features, let's understand the real-world problem that led to its creation. Facebook engineers faced what they called the "phantom message problem" - users would see a notification indicating new messages, but when they clicked on it, no new messages appeared.
This happened because Facebook's UI was built with traditional JavaScript and jQuery, where different parts of the interface were updated independently. The message count in the header might update, but the message list component wouldn't sync properly, creating inconsistent states across the application.
The Root Problem: Managing State Across Components
The fundamental issue was state management complexity. In traditional web applications:
- Multiple UI components need to reflect the same data
- When data changes, all related UI parts must update consistently
- Manual DOM manipulation becomes error-prone as applications grow
- No clear pattern for data flow leads to unpredictable bugs
React solved this by introducing a unidirectional data flow and a declarative approach to UI updates.
Why React Over Vue, Angular, and Other Frameworks?
While my initial preference was JavaScript's flexibility, React offers several compelling advantages:
1. Declarative Programming Model
// Instead of manually manipulating DOM
document.getElementById("counter").innerHTML = count;
document.getElementById("message").style.display = count > 0 ? "block" : "none";
// React lets you declare what the UI should look like
function Counter({ count }) {
return (
<div>
<span>{count}</span>
{count > 0 && <p>You have messages!</p>}
</div>
);
}
2. Component-Based Architecture
React's component system promotes reusability and maintainability:
// Reusable Button component
function Button({ onClick, children, variant = 'primary' }) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
>
{children}
</button>
);
}
// Use it anywhere
<Button onClick={handleSave} variant="success">Save</Button>
<Button onClick={handleCancel} variant="danger">Cancel</Button>
3. Strong Ecosystem and Community
- Massive npm package ecosystem
- Excellent tooling (React DevTools, Create React App)
- Strong corporate backing (Meta/Facebook)
- Extensive learning resources and community support
4. Performance Optimizations
React's Virtual DOM and Fiber architecture provide significant performance benefits over direct DOM manipulation.
Virtual DOM: The Game Changer
The virtual DOM (VDOM) is a programming concept where an ideal, or "virtual", representation of a UI is kept in memory and synced with the "real" DOM by a library such as ReactDOM. This process is called reconciliation.
How Virtual DOM Works
// When you write this JSX
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? "completed" : ""}>
{todo.text}
</li>
))}
</ul>
);
}
React creates a virtual representation:
// Virtual DOM representation (simplified)
{
type: 'ul',
props: {
children: [
{
type: 'li',
props: {
key: 1,
className: 'completed',
children: 'Buy groceries'
}
},
// ... more items
]
}
}
Benefits of Virtual DOM
- Performance: Batch updates and minimize expensive DOM operations
- Predictability: Declarative updates reduce bugs
- Cross-browser compatibility: React handles browser differences
- Developer experience: Write code describing desired state, not mutations
React Fiber: The Next-Level Architecture
In simple terms, a fiber represents a unit of work with its own virtual stack. React Fiber, introduced in React 16, revolutionized how React handles rendering and updates.
The Problem Fiber Solves
Before Fiber, React's reconciliation was synchronous - once it started updating components, it couldn't stop until finished. This caused:
- Frame drops during large updates
- Unresponsive user interfaces
- Poor user experience with animations
How Fiber Works
Introduced in React 16, Fiber enhances the reconciliation process, enabling React to break down rendering work into units and spread them out over multiple frames.
// Fiber enables React to:
// 1. Pause work and come back to it later
// 2. Assign priority to different types of work
// 3. Reuse previously completed work
// 4. Abort work if it's no longer needed
// High priority: User interactions
onClick={() => setCount(count + 1)}
// Low priority: Data fetching updates
useEffect(() => {
fetchData().then(setData);
}, []);
Reconciliation Process
The reconciliation process involves two main phases: the render phase and the commit phase.
- Render Phase: Build the new component tree (interruptible)
- Commit Phase: Apply changes to DOM (synchronous)
This two-phase approach allows React to:
- Prioritize urgent updates (user interactions)
- Defer less important updates (background data)
- Maintain smooth 60fps animations
Props: Component Communication
Props (properties) are how React components communicate with each other. They flow data down from parent to child components.
Passing Props
// Parent Component
function App() {
const user = {
name: "John Doe",
email: "john@example.com",
isActive: true,
};
return (
<div>
<UserProfile user={user} onEdit={handleUserEdit} showStatus={true} />
<UserSettings userId={user.id} preferences={user.preferences} />
</div>
);
}
Accepting Props
// Child Component
function UserProfile({ user, onEdit, showStatus }) {
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
{showStatus && (
<span className={user.isActive ? "active" : "inactive"}>
{user.isActive ? "Active" : "Inactive"}
</span>
)}
<button onClick={() => onEdit(user.id)}>Edit Profile</button>
</div>
);
}
React Hooks: The Modern Way
Hooks revolutionized React by allowing functional components to have state and lifecycle methods. But why were they needed?
The Re-render Problem
When state changes in a parent component, all child components re-render by default:
// Problem: Unnecessary re-renders
function App() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: "John" });
return (
<div>
<Header /> {/* Re-renders when count changes! */}
<Counter count={count} setCount={setCount} />
<UserProfile user={user} /> {/* Re-renders when count changes! */}
<Footer /> {/* Re-renders when count changes! */}
</div>
);
}
Re-render Flow Diagram
App Component (count state changes)
│
├── Header (unnecessary re-render)
├── Counter (necessary re-render)
├── UserProfile (unnecessary re-render)
│ ├── Avatar (unnecessary re-render)
│ └── Details (unnecessary re-render)
└── Footer (unnecessary re-render)
This is where optimization hooks become crucial.
1. useState: Managing Component State
What it does: useState lets you add state to functional components. It returns an array with the current state value and a function to update it.
When to use: Whenever you need to store and update data that affects what your component displays.
Basic Example
import { useState } from "react";
function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<div>
<p>Likes: {likes}</p>
<button onClick={() => setLikes(likes + 1)}>👍 Like</button>
</div>
);
}
Advanced Example with Objects
function UserForm() {
const [user, setUser] = useState({
name: "",
email: "",
age: "",
});
const updateUser = (field, value) => {
setUser((prevUser) => ({
...prevUser,
[field]: value,
}));
};
return (
<div>
<input
placeholder="Name"
value={user.name}
onChange={(e) => updateUser("name", e.target.value)}
/>
<input
placeholder="Email"
value={user.email}
onChange={(e) => updateUser("email", e.target.value)}
/>
<input
placeholder="Age"
value={user.age}
onChange={(e) => updateUser("age", e.target.value)}
/>
<p>
Hello {user.name}, you are {user.age} years old!
</p>
</div>
);
}
Key Points:
- Always use the setter function to update state
- For objects/arrays, create a new copy instead of modifying directly
- Use functional updates when new state depends on previous state
2. useEffect: Side Effects and Lifecycle
What it does: useEffect runs after your component renders and lets you perform side effects like API calls, subscriptions, or manually changing the DOM.
When to use: For data fetching, setting up subscriptions, timers, or cleaning up resources.
Basic Example - Component Did Mount
import { useState, useEffect } from "react";
function WelcomeMessage() {
const [message, setMessage] = useState("Loading...");
useEffect(() => {
// This runs after the component mounts
setTimeout(() => {
setMessage("Welcome to our app!");
}, 2000);
}, []); // Empty array means "run once after mount"
return <h1>{message}</h1>;
}
Data Fetching Example
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error("User not found");
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]); // Re-run when userId changes
if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
Cleanup Example
function LiveCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount((prevCount) => prevCount + 1);
}, 1000);
// Cleanup function - runs when component unmounts
return () => {
clearInterval(interval);
};
}, []); // Empty dependency array
return <h1>Count: {count}</h1>;
}
Key Points:
- Empty dependency array
[]
= run once after mount - No dependency array = run after every render
- Dependencies in array = run when those values change
- Return a cleanup function to prevent memory leaks
3. useMemo: Optimizing Expensive Calculations
What it does: useMemo caches the result of an expensive calculation and only recalculates when its dependencies change.
When to use: When you have expensive computations that don't need to run on every render.
Without useMemo (Problem)
function ProductList({ products, searchTerm }) {
// This expensive calculation runs on EVERY render
const filteredProducts = products
.filter((product) =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => b.rating - a.rating)
.slice(0, 10);
return (
<div>
{filteredProducts.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
With useMemo (Solution)
import { useMemo } from "react";
function ProductList({ products, searchTerm }) {
// This calculation only runs when products or searchTerm change
const filteredProducts = useMemo(() => {
console.log("Filtering products..."); // You'll see this less often
return products
.filter((product) =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => b.rating - a.rating)
.slice(0, 10);
}, [products, searchTerm]);
return (
<div>
{filteredProducts.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
Practical Example
function ShoppingCart({ items }) {
const [discount, setDiscount] = useState(0);
const cartSummary = useMemo(() => {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountAmount = subtotal * (discount / 100);
const total = subtotal - discountAmount;
return {
subtotal: subtotal.toFixed(2),
discountAmount: discountAmount.toFixed(2),
total: total.toFixed(2),
};
}, [items, discount]);
return (
<div>
<div>Subtotal: ${cartSummary.subtotal}</div>
<div>Discount: -${cartSummary.discountAmount}</div>
<div>Total: ${cartSummary.total}</div>
<input
type="number"
placeholder="Discount %"
onChange={(e) => setDiscount(Number(e.target.value))}
/>
</div>
);
}
Key Points:
- Only use for expensive computations
- Dependencies must include all values used inside useMemo
- Don't overuse - most calculations are fast enough without memoization
4. useCallback: Memoizing Functions
What it does: useCallback caches a function definition and only creates a new function when its dependencies change.
When to use: When passing functions to optimized child components or when functions are dependencies of other hooks.
Simple Example
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("");
// Without useCallback: new function on every render
// const handleIncrement = () => setCount(count + 1);
// With useCallback: function only recreated when needed
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []); // Empty because we use functional update
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
<ExpensiveChild onIncrement={handleIncrement} />
<p>Count: {count}</p>
</div>
);
}
const ExpensiveChild = memo(function ExpensiveChild({ onIncrement }) {
console.log("ExpensiveChild rendered"); // You'll see this less often
return <button onClick={onIncrement}>Increment</button>;
});
The Problem Without useCallback
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");
// New function created on every render!
const deleteTodo = (id) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
};
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={deleteTodo} // This causes TodoItem to re-render unnecessarily
/>
))}
</div>
);
}
The Solution With useCallback
import { useState, useCallback, memo } from "react";
function TodoApp() {
const [todos, setTodos] = useState([]);
// Function is cached and only changes if dependencies change
const deleteTodo = useCallback((id) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
}, []); // Empty because we use functional update
const toggleTodo = useCallback((id) => {
setTodos((todos) =>
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
))}
</div>
);
}
// Optimized component that won't re-render if props haven't changed
const TodoItem = memo(function TodoItem({ todo, onDelete, onToggle }) {
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
Key Points:
- Use with
memo()
wrapped components for best effect - Include all dependencies that the function uses
- Don't overuse - only when you have performance issues
5. useRef: Accessing DOM and Persisting Values
What it does: useRef creates a mutable reference that persists across re-renders without causing re-renders when changed.
When to use: For accessing DOM elements, storing mutable values, or keeping references to timers/intervals.
Use Case 1: DOM Access
import { useRef, useEffect } from "react";
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
// Focus the input when component mounts
inputRef.current.focus();
}, []);
const handleClear = () => {
inputRef.current.value = "";
inputRef.current.focus();
};
return (
<div>
<input
ref={inputRef}
type="text"
placeholder="I will be focused automatically"
/>
<button onClick={handleClear}>Clear and Focus</button>
</div>
);
}
Use Case 2: Storing Mutable Values
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
const startTimer = () => {
if (!isRunning) {
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime((prevTime) => prevTime + 1);
}, 100);
}
};
const stopTimer = () => {
setIsRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const resetTimer = () => {
stopTimer();
setTime(0);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div>
<h1>Time: {(time / 10).toFixed(1)}s</h1>
<button onClick={startTimer} disabled={isRunning}>
Start
</button>
<button onClick={stopTimer} disabled={!isRunning}>
Stop
</button>
<button onClick={resetTimer}>Reset</button>
</div>
);
}
Use Case 3: Previous Value Tracking
function ValueTracker() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
});
const prevCount = prevCountRef.current;
return (
<div>
<h1>Current: {count}</h1>
<h2>Previous: {prevCount}</h2>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
Key Points:
useRef
doesn't cause re-renders when.current
changes- Perfect for DOM manipulation and storing mutable values
- Use for timers, intervals, and previous values
- Always check if
.current
exists before using it
Hook Rules and Best Practices
Rules of Hooks
- Only call hooks at the top level - Never inside loops, conditions, or nested functions
- Only call hooks from React functions - Components or custom hooks
Best Practices
- Use functional updates when new state depends on previous state
- Include all dependencies in useEffect, useMemo, and useCallback
- Don't overuse memoization - measure first, optimize second
- Use custom hooks to share logic between components
- Clean up resources in useEffect to prevent memory leaks
Common Mistakes to Avoid
- Forgetting dependency arrays in useEffect
- Missing cleanup functions for subscriptions/timers
- Overusing useMemo and useCallback without performance issues
- Mutating state directly instead of creating new objects/arrays
Summary
- useState: Manages component state
- useEffect: Handles side effects and lifecycle events
- useMemo: Caches expensive calculations
- useCallback: Caches function definitions
- useRef: Accesses DOM elements and stores mutable values
These five hooks cover 90% of what you'll need in React applications. Master these, and you'll be well-equipped to build efficient, maintainable React components!
Custom Hooks: Reusable Logic
Custom hooks let you extract component logic into reusable functions:
// Custom hook for API data fetching
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch");
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error, refetch: () => fetchData() };
}
// Custom hook for localStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error("Error reading localStorage:", error);
return initialValue;
}
});
const setValue = useCallback(
(value) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error("Error setting localStorage:", error);
}
},
[key]
);
return [storedValue, setValue];
}
// Using custom hooks
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
const [preferences, setPreferences] = useLocalStorage("userPreferences", {});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>{user.name}</h2>
<UserSettings
preferences={preferences}
onPreferencesChange={setPreferences}
/>
</div>
);
}
React Router DOM Setup Guide
A comprehensive guide to setting up and using React Router DOM for client-side routing in React applications.
Installation
npm install react-router-dom
Basic Setup
1. Wrap your app with BrowserRouter
// main.jsx
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
2. Add Routes to your components
// App.jsx
import { Routes, Route, Link } from "react-router-dom";
function App() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
);
}
That's it! This covers the basic setup that most projects need.
Note: React Router DOM is a comprehensive routing library with many advanced features including nested routes, route parameters, programmatic navigation, protected routes, data loading, and more. For detailed explanations of these advanced features, refer to the official React Router documentation.
We’ve covered the core concepts of React—from understanding the Virtual DOM to working with essential Hooks and routing. Next, we’ll move into powerful tools and patterns like the Context API for managing global state, Redux and Zustand for handling complex scenarios.
Ready to level up? Continue your React mastery journey with Part 2: State Management in React: Context, Zustand, and Redux.
Want to dive deeper into modern web development? Check out my other
Related Posts: