React Essentials: From Components to Routing

November 18, 2024 (7mo ago)

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:

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

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

  1. Performance: Batch updates and minimize expensive DOM operations
  2. Predictability: Declarative updates reduce bugs
  3. Cross-browser compatibility: React handles browser differences
  4. 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:

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.

  1. Render Phase: Build the new component tree (interruptible)
  2. Commit Phase: Apply changes to DOM (synchronous)

This two-phase approach allows React to:

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:


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:


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:


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:


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:


Hook Rules and Best Practices

Rules of Hooks

  1. Only call hooks at the top level - Never inside loops, conditions, or nested functions
  2. Only call hooks from React functions - Components or custom hooks

Best Practices

  1. Use functional updates when new state depends on previous state
  2. Include all dependencies in useEffect, useMemo, and useCallback
  3. Don't overuse memoization - measure first, optimize second
  4. Use custom hooks to share logic between components
  5. Clean up resources in useEffect to prevent memory leaks

Common Mistakes to Avoid

Summary

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: