useState và useEffect: Siêu Năng Lực Nhập Môn của React (React Roadmap)

Chào mừng các bạn quay trở lại với series React Roadmap! Trên hành trình làm quen với React, chúng ta đã đi qua những viên gạch đầu tiên như React là gì, JSX, cách làm việc với Component (cả Class và Functional) và cách dữ liệu chảy trong ứng dụng thông qua Props và State.

Trong thế giới React hiện đại, đặc biệt khi làm việc với các Functional Component (Thành phần hàm), chúng ta không còn sử dụng cú pháp Class Component rườm rà với this và các phương thức vòng đời (lifecycle methods) như componentDidMount hay componentDidUpdate nữa. Thay vào đó, React giới thiệu một khái niệm mạnh mẽ: **Hooks**. Hooks cho phép chúng ta “móc nối” (hook into) các tính năng của React từ các hàm component.

Và hai trong số những Hooks quan trọng và được sử dụng nhiều nhất, là nền tảng để bạn làm chủ Functional Component, chính là useStateuseEffect. Chúng ta có thể gọi chúng là “siêu năng lực nhập môn” bởi vì nắm vững hai hook này, bạn đã có thể xây dựng được rất nhiều ứng dụng React thực tế. Bài viết này sẽ đi sâu vào cách sử dụng và hiểu rõ sức mạnh của chúng.

useState: Quản lý State trong Functional Component

Như chúng ta đã tìm hiểu trong bài viết về Props và State, State là dữ liệu *nội bộ* của một component, có thể thay đổi theo thời gian và khi State thay đổi, component sẽ được render lại. Với Functional Component, useState chính là cách chúng ta khai báo và quản lý State.

Cách sử dụng useState

useState là một hàm nhận vào một đối số duy nhất: giá trị State khởi tạo (initial state). Nó trả về một mảng gồm hai phần tử:

  1. Phần tử đầu tiên là giá trị State hiện tại.
  2. Phần tử thứ hai là một hàm (gọi là setter function) dùng để cập nhật giá trị State đó.

Chúng ta thường sử dụng cú pháp “destructuring array” (phá vỡ cấu trúc mảng) để dễ dàng truy cập hai phần tử này:

import React, { useState } from 'react';

function Counter() {
  // Khai báo một state variable tên là 'count'
  // với giá trị khởi tạo là 0.
  // 'count' sẽ là giá trị hiện tại, 'setCount' là hàm cập nhật.
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Bạn đã click {count} lần</p>
      <button onClick={() => setCount(count + 1)}>
        Click vào đây
      </button>
    </div>
  );
}

Trong ví dụ trên:

  • useState(0): Khởi tạo State count với giá trị ban đầu là 0.
  • const [count, setCount]: Gán giá trị State hiện tại vào biến count và hàm cập nhật vào biến setCount. Tên biến countsetCount là do chúng ta tự đặt, nhưng theo quy ước, hàm setter thường có tiền tố set + tên State variable.
  • Khi người dùng click vào button, hàm onClick được gọi, nó gọi setCount(count + 1).
  • Gọi setCount sẽ cập nhật giá trị của count và báo cho React biết rằng State đã thay đổi.
  • React sẽ render lại component Counter với giá trị count mới.

Lưu ý khi cập nhật State với useState

  • Cập nhật không đồng bộ (Asynchronous Updates): Giống như this.setState trong Class Component, việc gọi hàm setter của useState không ngay lập tức thay đổi giá trị State trong dòng code tiếp theo. React có thể nhóm các cập nhật State lại để cải thiện hiệu suất. Nếu bạn cần thực hiện hành động gì đó *sau khi* State đã chắc chắn được cập nhật, hãy sử dụng useEffect.
  • Cập nhật dựa trên giá trị trước đó (Functional Updates): Nếu giá trị State mới phụ thuộc vào giá trị State trước đó (như ví dụ counter setCount(count + 1)), cách an toàn hơn và được khuyến khích là truyền vào hàm setter một hàm callback. Hàm callback này nhận giá trị State trước đó làm đối số và trả về giá trị State mới.
setCount(prevCount => prevCount + 1); // Cách tốt hơn khi phụ thuộc vào giá trị trước đó
  • Immutability (Tính bất biến): Khi State là object hoặc array, bạn *không được* thay đổi trực tiếp object/array đó. Thay vào đó, hãy tạo một object/array mới với các thay đổi và truyền object/array mới đó vào hàm setter.
// State là object
const [user, setUser] = useState({ name: 'Alice', age: 30 });

// SAI: Thay đổi trực tiếp object
// user.age = 31;
// setUser(user); // Có thể không hoạt động như mong đợi hoặc gây bug

// ĐÚNG: Tạo object mới
setUser({ ...user, age: 31 });

// State là array
const [items, setItems] = useState(['item 1', 'item 2']);

// SAI: Thay đổi trực tiếp array
// items.push('item 3');
// setItems(items); // Có thể không hoạt động như mong đợi hoặc gây bug

// ĐÚNG: Tạo array mới
setItems([...items, 'item 3']);

Việc tuân thủ tính bất biến là cực kỳ quan trọng trong React để đảm bảo cơ chế phát hiện thay đổi (diffing) và render lại hoạt động chính xác.

useEffect: Xử lý Side Effects

Trong Functional Component, render là quá trình React gọi hàm component của bạn để xác định giao diện người dùng hiện tại. Các hành động như thay đổi State, Props, hoặc Context sẽ kích hoạt quá trình render này.

Tuy nhiên, có những hành động không phải là một phần của quá trình render giao diện, ví dụ:

  • Lấy dữ liệu từ API.
  • Đăng ký/hủy đăng ký các sự kiện (event listeners).
  • Thay đổi tiêu đề trang (document title).
  • Tương tác trực tiếp với DOM (mặc dù React khuyến khích sử dụng State/Props để điều khiển DOM).
  • Thiết lập hoặc xóa các timer (setTimeout, setInterval).

Những hành động này được gọi là **side effects** (tác dụng phụ) vì chúng “ảnh hưởng” đến môi trường bên ngoài hàm component hoặc gây ra thay đổi không đồng bộ. Với Functional Component, useEffect là Hook được thiết kế để xử lý các side effects này.

Trước khi Hooks ra đời, chúng ta thường xử lý các side effects trong các phương thức vòng đời của Class Component như componentDidMount, componentDidUpdate, và componentWillUnmount (Các bạn có thể xem lại bài Vòng đời Component). useEffect cung cấp một cách tổng hợp và đơn giản hơn để xử lý tất cả các trường hợp này.

Cách sử dụng useEffect

useEffect nhận vào hai đối số:

  1. Một hàm callback: Đây là nơi bạn đặt code xử lý side effect của mình.
  2. Một mảng dependencies (tùy chọn): Đây là danh sách các giá trị (State, Props, biến khác) mà effect của bạn phụ thuộc vào. Sự thay đổi của bất kỳ giá trị nào trong mảng này sẽ kích hoạt lại effect.
import React, { useState, useEffect } from 'react';

function ExampleComponent() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  // Case 1: Chạy effect sau mỗi lần render (ít dùng)
  // useEffect(() => {
  //   console.log('Component đã render hoặc update');
  // });

  // Case 2: Chạy effect CHỈ MỘT LẦN sau lần render đầu tiên (tương tự componentDidMount)
  useEffect(() => {
    console.log('Component đã mount');
    document.title = 'Ví dụ useEffect'; // Thay đổi tiêu đề trang

    // Ví dụ fetch data (sử dụng async/await bên trong useEffect requires một wrapper function)
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data'); // Thay bằng API thật
        const result = await response.json();
        setData(result);
        setIsLoading(false);
      } catch (error) {
        console.error('Lỗi khi lấy dữ liệu:', error);
        setIsLoading(false);
      }
    };

    fetchData();

    // Đây là cleanup function (tương tự componentWillUnmount)
    // Nó sẽ chạy trước khi component unmount hoặc trước khi effect chạy lại
    return () => {
      console.log('Cleanup cho mount effect');
      // Ví dụ: hủy đăng ký sự kiện, xóa timer...
    };
  }, []); // Mảng rỗng nghĩa là effect này chỉ chạy một lần sau khi component mount

  // Case 3: Chạy effect khi một giá trị trong dependency array thay đổi (tương tự componentDidUpdate)
  const [userId, setUserId] = useState(1);

  useEffect(() => {
    console.log('userId đã thay đổi hoặc component mount');
    // Fetch dữ liệu user dựa trên userId
    const fetchUser = async () => {
        // ... logic fetch user ...
    };
    fetchUser();

    return () => {
        console.log('Cleanup cho userId effect');
        // Ví dụ: Abort fetch request cũ nếu có
    };
  }, [userId]); // Effect này chạy khi userId thay đổi hoặc component mount

  // ... render UI dựa trên data, isLoading, userId ...
  return (
      <div>
          <h2>Ví dụ về useEffect</h2>
          {isLoading ? <p>Đang tải...</p> : <p>Dữ liệu đã tải xong: {JSON.stringify(data)}</p>}
          <p>User ID: {userId}</p>
          <button onClick={() => setUserId(userId + 1)}>Next User</button>
      </div>
  );
}

Các trường hợp sử dụng dependency array:

  • Không có mảng dependencies: Effect chạy sau *mỗi* lần render component. Thường không mong muốn vì có thể gây ra vòng lặp vô hạn (ví dụ: effect cập nhật state, state thay đổi gây render, render lại kích hoạt effect, effect lại cập nhật state…). Chỉ sử dụng khi bạn thực sự muốn chạy một đoạn code sau mỗi lần render.
  • Mảng rỗng []: Effect chỉ chạy *một lần* sau lần render đầu tiên (khi component “mount”). Nó không chạy lại khi State hoặc Props thay đổi. Đây là trường hợp phổ biến cho các hành động khởi tạo như lấy dữ liệu ban đầu.
  • Mảng có giá trị [propA, stateB]: Effect chạy sau lần render đầu tiên và sau mỗi lần *bất kỳ* giá trị nào trong mảng dependencies thay đổi. React sẽ so sánh giá trị hiện tại và giá trị trước đó của các phần tử trong mảng. Nếu có sự khác biệt, effect sẽ chạy lại.

Cleanup Function

Đôi khi, side effect cần “dọn dẹp” sau khi hoàn thành hoặc trước khi effect chạy lại. Ví dụ: hủy đăng ký sự kiện, xóa timer, đóng kết nối. Bạn có thể làm điều này bằng cách trả về một hàm từ callback của useEffect. Hàm này sẽ được gọi:

  • Trước khi component bị xóa khỏi DOM (unmount).
  • Trước khi effect chạy lại do dependencies thay đổi.

Điều này giúp ngăn chặn rò rỉ bộ nhớ hoặc các hành vi không mong muốn.

useEffect(() => {
  const timerId = setInterval(() => {
    console.log('Timer tick');
  }, 1000);

  // Cleanup function: xóa timer khi component unmount hoặc dependencies thay đổi
  return () => {
    clearInterval(timerId);
    console.log('Timer cleared');
  };
}, []); // Chỉ thiết lập timer một lần khi mount

useState và useEffect Làm Việc Cùng Nhau

Trong thực tế, useStateuseEffect thường được sử dụng kết hợp. Bạn có thể sử dụng useState để lưu trữ dữ liệu và trạng thái UI (như loading, error), và sử dụng useEffect để thực hiện các side effects (như fetching data) dựa trên sự thay đổi của State hoặc Props. Kết quả từ side effect (ví dụ: dữ liệu fetched) sau đó sẽ được lưu trữ vào State bằng hàm setter của useState, kích hoạt render lại UI.

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset state khi userId thay đổi
    setLoading(true);
    setError(null);
    setUserData(null); // Tùy chọn: xóa data cũ khi user mới đang tải

    console.log(`Fetching data for user ID: ${userId}`);

    // Function để fetch dữ liệu
    const fetchUserData = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        setUserData(data); // Cập nhật state với dữ liệu fetched
      } catch (err) {
        setError(err); // Cập nhật state khi có lỗi
      } finally {
        setLoading(false); // Luôn dừng trạng thái loading
      }
    };

    fetchUserData();

    // Không cần cleanup ở đây nếu chỉ fetch một lần cho mỗi ID thay đổi
    // Nếu dùng EventSource hoặc Subscription thì cần cleanup
    // return () => { /* Cleanup code here */ };

  }, [userId]); // Effect chạy khi userId prop thay đổi

  if (loading) {
    return <p>Đang tải thông tin người dùng...</p>;
  }

  if (error) {
    return <p>Lỗi khi tải thông tin: {error.message}</p>;
  }

  if (!userData) {
      return <p>Không tìm thấy thông tin người dùng.</p>;
  }

  return (
    <div>
      <h3>Thông tin người dùng</h3>
      <p><strong>Tên:</strong> {userData.name}</p>
      <p><strong>Email:</strong> {userData.email}</p>
      {/* Render thêm thông tin khác */}
    </div>
  );
}

Trong ví dụ này, useEffect được sử dụng để fetch dữ liệu người dùng dựa trên userId prop. Khi userId thay đổi (được đưa vào mảng dependencies), effect sẽ chạy lại, fetch dữ liệu mới và cập nhật State userData, loading, error thông qua các hàm setter của useState. UI sẽ tự động render lại để phản ánh trạng thái và dữ liệu mới nhất.

Best Practices và Những Lưu Ý Quan Trọng

  • Rules of Hooks: Có hai quy tắc vàng khi sử dụng Hooks:
    1. Chỉ gọi Hooks ở cấp cao nhất (top level) trong Functional Component hoặc custom Hooks. Không gọi Hooks bên trong vòng lặp, câu lệnh điều kiện, hoặc các hàm lồng nhau khác. Điều này đảm bảo React gọi Hooks theo cùng một thứ tự trong mỗi lần render.
    2. Chỉ gọi Hooks từ Functional Component React hoặc custom Hooks. Không gọi Hooks từ các hàm JavaScript thông thường khác.

    Tuân thủ các quy tắc này là bắt buộc để Hooks hoạt động đúng.

  • Dependencies của useEffect: Luôn đảm bảo mảng dependencies của useEffect chứa *tất cả* các giá trị từ phạm vi component (props, state, hàm…) mà effect của bạn sử dụng và phụ thuộc vào. Nếu bỏ sót một dependency, effect có thể sử dụng giá trị cũ (“stale closure”) từ lần render trước, dẫn đến hành vi không mong muốn. React khuyến cáo sử dụng ESLint rule exhaustive-deps để kiểm tra và cảnh báo về các dependencies bị thiếu.
  • Hàm trong Dependencies: Nếu effect của bạn sử dụng một hàm được định nghĩa bên trong component, bạn cần đưa hàm đó vào dependency array. Để tránh việc effect chạy lại không cần thiết nếu hàm đó được tạo lại sau mỗi render, bạn có thể sử dụng Hook useCallback để ghi nhớ hàm đó. (Đây là một chủ đề nâng cao hơn, nhưng đáng để ghi nhớ).

Tổng Kết Nhanh: useState vs useEffect

Để dễ hình dung, dưới đây là bảng so sánh ngắn gọn giữa hai Hook này:

Đặc điểm useState useEffect
Mục đích chính Quản lý State (dữ liệu nội bộ) trong Functional Component. Lưu trữ các giá trị có thể thay đổi theo thời gian. Thực hiện các Side Effects (hành động bên ngoài luồng render) và đồng bộ component với hệ thống bên ngoài.
Đối số Giá trị State khởi tạo (có thể là giá trị cố định hoặc hàm).
  1. Hàm callback chứa logic side effect.
  2. Mảng dependencies (tùy chọn).
Giá trị trả về Mảng [giá_trị_state, hàm_cập_nhật_state]. Không trả về gì, nhưng hàm callback có thể trả về một hàm cleanup.
Khi nào chạy? Hàm setter chạy khi được gọi (thường trong event handler hoặc useEffect). React render lại component sau khi State thay đổi. Mặc định: Chạy sau mỗi lần render.

Với []: Chạy một lần sau render đầu tiên (mount).

Với [deps]: Chạy sau render đầu tiên và khi bất kỳ dependency nào thay đổi.
Cleanup Không có cơ chế cleanup trực tiếp. Có thể trả về một hàm cleanup để chạy trước khi component unmount hoặc trước khi effect chạy lại.
Ví dụ điển hình Đếm số lần click, lưu trữ input từ người dùng, quản lý trạng thái bật/tắt (toggle). Fetch data từ API, thay đổi tiêu đề trang, thiết lập/xóa timer, đăng ký/hủy đăng ký event listener.

Kết Luận

useStateuseEffect là hai Hook cơ bản nhưng cực kỳ mạnh mẽ, mở ra khả năng quản lý State và Side Effects trong Functional Component một cách rõ ràng và linh hoạt hơn so với Class Component truyền thống. Nắm vững cách sử dụng chúng là bước đệm vững chắc để bạn tiến sâu hơn vào thế giới React Hooks và xây dựng các ứng dụng phức tạp hơn.

Hãy dành thời gian thực hành với các ví dụ đơn giản, thử nghiệm các trường hợp khác nhau của dependency array trong useEffect và làm quen với việc quản lý State bằng useState. Đây chính là nền tảng để bạn tiếp tục khám phá các Hooks khác và các khái niệm nâng cao hơn trong Lộ trình React Roadmap.

Trong bài viết tiếp theo, chúng ta sẽ khám phá các Hooks khác hữu ích như useContext, useReducer, và useRef, giúp bạn giải quyết các bài toán quản lý State toàn cục và tương tác trực tiếp với DOM một cách hiệu quả hơn.

Chỉ mục