React.js Fundamentals

Component Lifecycle & Performance

18 min Lesson 19 of 40

Introduction to Component Lifecycle

Understanding the React component lifecycle and optimization techniques is crucial for building performant applications. This lesson covers lifecycle concepts in modern React, performance optimization with React.memo, useMemo, useCallback, and profiling tools.

Component Lifecycle in Functional Components

Functional components use hooks to handle lifecycle events. The useEffect hook replaces class component lifecycle methods:

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // ComponentDidMount + ComponentDidUpdate (when userId changes)
  useEffect(() => {
    console.log('Component mounted or userId changed');

    setLoading(true);
    fetch(`https://api.example.com/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });

    // ComponentWillUnmount (cleanup function)
    return () => {
      console.log('Cleanup before re-running effect or unmounting');
    };
  }, [userId]); // Dependency array - effect runs when userId changes

  // Runs only on mount (empty dependency array)
  useEffect(() => {
    console.log('Component mounted once');
    document.title = `User Profile - ${userId}`;

    return () => {
      console.log('Component unmounting');
      document.title = 'React App';
    };
  }, []); // Empty array = mount/unmount only

  // Runs on every render (no dependency array - avoid this!)
  useEffect(() => {
    console.log('Runs after every render');
  });

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Lifecycle Equivalents:

  • useEffect(() => {}, []) = componentDidMount
  • useEffect(() => {}) = componentDidMount + componentDidUpdate
  • return () => {} in useEffect = componentWillUnmount

React.memo: Preventing Unnecessary Re-renders

React.memo is a higher-order component that memoizes component output, preventing re-renders when props haven't changed:

import { memo } from 'react';

// Without memo - re-renders every time parent re-renders
function ExpensiveComponent({ data, count }) {
  console.log('ExpensiveComponent rendered');

  // Imagine expensive calculations here
  const processedData = data.map(item => ({
    ...item,
    calculated: item.value * 1000
  }));

  return (
    <div>
      <h3>Count: {count}</h3>
      <ul>
        {processedData.map(item => (
          <li key={item.id}>{item.calculated}</li>
        ))}
      </ul>
    </div>
  );
}

// With memo - only re-renders when props change
const MemoizedExpensiveComponent = memo(ExpensiveComponent);

// Usage
function ParentComponent() {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);

  const data = [
    { id: 1, value: 10 },
    { id: 2, value: 20 },
    { id: 3, value: 30 }
  ];

  return (
    <div>
      {/* This will re-render only when count or data changes */}
      <MemoizedExpensiveComponent data={data} count={count} />

      <button onClick={() => setCount(c => c + 1)}>
        Increment Count
      </button>

      {/* This won't trigger MemoizedExpensiveComponent re-render */}
      <button onClick={() => setOtherState(s => s + 1)}>
        Update Other State ({otherState})
      </button>
    </div>
  );
}

When to use React.memo: Use it for components that render often with the same props, especially if they perform expensive calculations or render large lists.

Custom Comparison Function with React.memo

You can provide a custom comparison function for complex prop comparisons:

import { memo } from 'react';

function UserCard({ user, onEdit }) {
  console.log('UserCard rendered');

  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>Edit</button>
    </div>
  );
}

// Custom comparison: only re-render if user.id or user.name changed
const MemoizedUserCard = memo(UserCard, (prevProps, nextProps) => {
  // Return true if props are equal (skip render)
  // Return false if props are different (re-render)
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name
    // Note: onEdit function reference changes won't trigger re-render
  );
});

export default MemoizedUserCard;

Warning: React.memo uses shallow comparison by default. For objects and arrays, provide a custom comparison function or ensure reference stability.

useMemo: Memoizing Expensive Calculations

useMemo caches the result of expensive calculations between renders:

import { useState, useMemo } from 'react';

function ProductList({ products, filter }) {
  const [sortOrder, setSortOrder] = useState('asc');

  // Without useMemo - recalculated on every render
  const filteredProducts = products
    .filter(p => p.category === filter)
    .sort((a, b) => {
      return sortOrder === 'asc'
        ? a.price - b.price
        : b.price - a.price;
    });

  // With useMemo - only recalculated when dependencies change
  const memoizedFilteredProducts = useMemo(() => {
    console.log('Recalculating filtered products');

    return products
      .filter(p => p.category === filter)
      .sort((a, b) => {
        return sortOrder === 'asc'
          ? a.price - b.price
          : b.price - a.price;
      });
  }, [products, filter, sortOrder]); // Dependencies

  return (
    <div>
      <button onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}>
        Sort: {sortOrder}
      </button>

      <ul>
        {memoizedFilteredProducts.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

// More useMemo examples
function AnalyticsDashboard({ data }) {
  // Expensive calculations
  const statistics = useMemo(() => {
    console.log('Calculating statistics...');

    const total = data.reduce((sum, item) => sum + item.value, 0);
    const average = total / data.length;
    const max = Math.max(...data.map(d => d.value));
    const min = Math.min(...data.map(d => d.value));

    return { total, average, max, min };
  }, [data]);

  return (
    <div>
      <p>Total: {statistics.total}</p>
      <p>Average: {statistics.average.toFixed(2)}</p>
      <p>Max: {statistics.max}</p>
      <p>Min: {statistics.min}</p>
    </div>
  );
}

Note: Don't overuse useMemo. The memoization itself has overhead. Only use it for genuinely expensive calculations or to maintain reference equality.

useCallback: Memoizing Functions

useCallback returns a memoized callback function, preventing unnecessary re-renders of child components:

import { useState, useCallback, memo } from 'react';

// Child component that receives a callback
const TodoItem = memo(({ todo, onToggle, onDelete }) => {
  console.log(`TodoItem ${todo.id} rendered`);

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
});

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', completed: false },
    { id: 2, text: 'Build Project', completed: false },
    { id: 3, text: 'Deploy App', completed: false }
  ]);
  const [filter, setFilter] = useState('all');

  // Without useCallback - new function on every render
  // This would cause TodoItem to re-render even with React.memo
  const handleToggle = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  // With useCallback - same function reference between renders
  const handleToggleMemoized = useCallback((id) => {
    setTodos(prevTodos => prevTodos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []); // Empty deps - function never changes

  const handleDelete = useCallback((id) => {
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  }, []);

  // Function with dependencies
  const handleAddTodo = useCallback((text) => {
    const newTodo = {
      id: Date.now(),
      text,
      completed: false
    };
    setTodos(prevTodos => [...prevTodos, newTodo]);
  }, []); // Could have dependencies if needed

  return (
    <div>
      <button onClick={() => setFilter('all')}>All</button>
      <button onClick={() => setFilter('active')}>Active</button>
      <button onClick={() => setFilter('completed')}>Completed</button>

      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggleMemoized}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
}

Best Practice: Use useCallback when passing callbacks to memoized child components. Without it, child components will re-render because the callback reference changes.

Combining Optimization Techniques

Real-world example combining React.memo, useMemo, and useCallback:

import { useState, useMemo, useCallback, memo } from 'react';

// Memoized child component
const DataRow = memo(({ item, onUpdate, onDelete }) => {
  console.log(`Row ${item.id} rendered`);

  return (
    <tr>
      <td>{item.name}</td>
      <td>{item.value}</td>
      <td>
        <button onClick={() => onUpdate(item.id, item.value + 1)}>+</button>
        <button onClick={() => onDelete(item.id)}>Delete</button>
      </td>
    </tr>
  );
});

function OptimizedDataTable({ initialData }) {
  const [data, setData] = useState(initialData);
  const [filter, setFilter] = useState('');
  const [sortBy, setSortBy] = useState('name');

  // Memoized filtered and sorted data
  const processedData = useMemo(() => {
    console.log('Processing data...');

    let result = data;

    // Filter
    if (filter) {
      result = result.filter(item =>
        item.name.toLowerCase().includes(filter.toLowerCase())
      );
    }

    // Sort
    result = [...result].sort((a, b) => {
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      if (sortBy === 'value') return a.value - b.value;
      return 0;
    });

    return result;
  }, [data, filter, sortBy]);

  // Memoized callbacks
  const handleUpdate = useCallback((id, newValue) => {
    setData(prevData =>
      prevData.map(item =>
        item.id === id ? { ...item, value: newValue } : item
      )
    );
  }, []);

  const handleDelete = useCallback((id) => {
    setData(prevData => prevData.filter(item => item.id !== id));
  }, []);

  return (
    <div>
      <input
        type="text"
        placeholder="Filter by name..."
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
      />

      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="name">Sort by Name</option>
        <option value="value">Sort by Value</option>
      </select>

      <table>
        <tbody>
          {processedData.map(item => (
            <DataRow
              key={item.id}
              item={item}
              onUpdate={handleUpdate}
              onDelete={handleDelete}
            />
          ))}
        </tbody>
      </table>
    </div>
  );
}

React DevTools Profiler

Use React DevTools Profiler to identify performance bottlenecks:

// Install React DevTools browser extension
// Chrome: https://chrome.google.com/webstore (search "React Developer Tools")
// Firefox: https://addons.mozilla.org/firefox/ (search "React Developer Tools")

// Using the Profiler programmatically
import { Profiler } from 'react';

function onRenderCallback(
  id,        // Component identifier
  phase,     // "mount" or "update"
  actualDuration, // Time spent rendering
  baseDuration,   // Estimated time without memoization
  startTime,
  commitTime
) {
  console.log(`Component ${id} - ${phase}`, {
    actualDuration,
    baseDuration,
    improvement: baseDuration - actualDuration
  });
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <TodoList />
      <DataTable />
    </Profiler>
  );
}

Exercise 1: Optimize Product List

Task: Optimize a product list component:

  • Create a list of 100+ products with search and filter
  • Use React.memo on the ProductCard component
  • Use useMemo to memoize filtered/sorted results
  • Use useCallback for event handlers
  • Measure performance improvement with React DevTools Profiler

Exercise 2: Lifecycle Management

Task: Build a timer component with proper cleanup:

  • Create a countdown timer that updates every second
  • Use useEffect with proper cleanup (clear interval on unmount)
  • Add pause/resume functionality
  • Prevent memory leaks when component unmounts
  • Add document title update showing remaining time

Exercise 3: Complex Data Dashboard

Task: Build an optimized analytics dashboard:

  • Display charts with expensive calculations (mean, median, standard deviation)
  • Use useMemo for statistical calculations
  • Implement filters and date range selectors
  • Memoize child chart components with React.memo
  • Compare performance before and after optimization

Summary

In this lesson, you learned essential performance optimization techniques:

  • Component lifecycle in functional components with useEffect
  • Preventing unnecessary re-renders with React.memo
  • Memoizing expensive calculations with useMemo
  • Memoizing callback functions with useCallback
  • Combining optimization techniques for maximum performance
  • Profiling and measuring performance with React DevTools
  • Best practices for when and how to apply optimizations

In the next lesson, we'll explore error boundaries, Suspense, and advanced error handling patterns in React.