Bài 15.4: useState và useEffect với TypeScript

Chào mừng các bạn trở lại! Hôm nay, chúng ta sẽ khám phá hai bộ đôi hoàn hảo trong thế giới React: useStateuseEffect. Đây là những "bí kíp" giúp chúng ta quản lý trạng thái (state) và xử lý các tác vụ phụ (side effects) trong các component function của mình. Và tuyệt vời hơn nữa, chúng ta sẽ tích hợp sức mạnh của TypeScript để làm cho code của chúng ta an toàn, dễ bảo trìmạnh mẽ hơn bao giờ hết!

Hãy cùng bắt đầu hành trình chinh phục useStateuseEffect cùng với TypeScript nhé!

useState: Quản lý Trạng thái (State) trong Component của Bạn

Trong lập trình Front-end, "trạng thái" (state) đề cập đến dữ liệu có thể thay đổi theo thời gian và ảnh hưởng đến cách component của bạn hiển thị hoặc hoạt động. Ví dụ: giá trị nhập liệu của người dùng, trạng thái loading, danh sách các mục hiển thị, v.v.

Trong các component function của React, hook useState là công cụ chính giúp chúng ta tạo và quản lý những trạng thái này một cách hiệu quả.

Cú pháp cơ bản của useState trông như thế này:

import React, { useState } from 'react';

const [stateVariable, setStateFunction] = useState(initialValue);
  • stateVariable: Biến này sẽ lưu trữ giá trị hiện tại của trạng thái.
  • setStateFunction: Đây là một hàm mà bạn gọi để cập nhật giá trị của trạng thái. Khi hàm này được gọi, React sẽ re-render component với giá trị trạng thái mới.
  • initialValue: Giá trị khởi tạo cho trạng thái. Nó có thể là bất kỳ kiểu dữ liệu nào (số, chuỗi, boolean, đối tượng, mảng, null, undefined).
TypeScript và useState: Thêm Lớp Bảo Vệ

Sức mạnh của TypeScript phát huy khi chúng ta sử dụng useState bằng cách chỉ định rõ ràng kiểu dữ liệu của trạng thái. Điều này giúp TypeScript kiểm tra lỗi ngay trong quá trình phát triển, ngăn chặn chúng ta gán sai kiểu dữ liệu cho trạng thái.

Đây là cách chúng ta khai báo trạng thái với kiểu dữ liệu cụ thể:

import React, { useState } from 'react';

// Khai báo trạng thái kiểu number
const [count, setCount] = useState<number>(0);

// Khai báo trạng thái kiểu string
const [name, setName] = useState<string>('');

// Khai báo trạng thái kiểu boolean
const [isLoading, setIsLoading] = useState<boolean>(false);

Trong các ví dụ trên, <number>, <string>, <boolean> là các generic type mà chúng ta truyền cho useState. Điều này báo cho TypeScript biết rằng count luôn là một số, name luôn là một chuỗi và isLoading luôn là một boolean.

Ví dụ với Object và Array:

Khi trạng thái là đối tượng hoặc mảng, việc định nghĩa kiểu dữ liệu càng trở nên quan trọng để đảm bảo tính nhất quán của dữ liệu.

import React, { useState } from 'react';

// Định nghĩa kiểu cho một đối tượng User
interface User {
  id: number;
  name: string;
  age: number;
}

// Khai báo trạng thái kiểu User (có thể là null ban đầu)
const [user, setUser] = useState<User | null>(null);

// Định nghĩa kiểu cho một mảng các đối tượng Todo
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// Khai báo trạng thái kiểu mảng các Todo
const [todos, setTodos] = useState<Todo[]>([]);

Trong ví dụ trên:

  • Chúng ta định nghĩa interface Userinterface Todo để mô tả cấu trúc dữ liệu của đối tượng và các phần tử trong mảng.
  • useState<User | null>(null): Báo cho TypeScript biết rằng user có thể là một đối tượng User hoặc null. Giá trị khởi tạo là null.
  • useState<Todo[]>([]): Báo cho TypeScript biết rằng todos là một mảng các đối tượng Todo. Giá trị khởi tạo là một mảng rỗng.

Tại sao lại dùng TypeScript ở đây?

Imagine bạn có trạng thái user và bạn cố gắng gán một giá trị không đúng kiểu, ví dụ: setUser("Tôi là một chuỗi!"). Nếu không có TypeScript, lỗi này có thể không xuất hiện cho đến khi runtime, gây khó khăn trong việc debug. Với TypeScript, trình biên dịch sẽ báo lỗi ngay lập tức, giúp bạn phát hiện và sửa lỗi trước khi chạy ứng dụng. Điều này cực kỳ hữu ích cho các ứng dụng lớn và phức tạp.

useEffect: Xử lý Tác vụ Phụ (Side Effects) Đỉnh Cao

Trong một ứng dụng React thuần túy, việc render UI dựa trên trạng thái và props là mục tiêu chính. Tuy nhiên, trong thực tế, chúng ta thường cần thực hiện các tác vụ "phụ" không trực tiếp liên quan đến việc render, như:

  • Gọi API để lấy dữ liệu.
  • Thao tác trực tiếp với DOM (ví dụ: thiết lập tiêu đề trang).
  • Thiết lập hoặc xóa các listeners cho sự kiện (event listeners).
  • Thiết lập hoặc xóa các bộ đếm thời gian (timers).
  • Thiết lập các subscription.

Hook useEffect là câu trả lời của React cho việc xử lý các side effects này trong các component function.

Cú pháp cơ bản của useEffect trông như thế này:

import React, { useEffect } from 'react';

useEffect(() => {
  // Code xử lý side effect của bạn
  // ...

  // (Tùy chọn) Hàm cleanup
  return () => {
    // Code dọn dẹp (hủy subscription, xóa timer, v.v.)
    // ...
  };
}, [dependencies]); // Mảng phụ thuộc (dependency array)
  • Tham số đầu tiên: Một hàm (gọi là effect function) chứa code xử lý side effect.
  • Tham số thứ hai (tùy chọn): Một mảng các giá trị (gọi là dependency array). React sẽ chạy lại effect function mỗi khi bất kỳ giá trị nào trong mảng này thay đổi.
Các trường hợp sử dụng phổ biến của Dependency Array:
  1. Không có mảng phụ thuộc: useEffect(() => { ... });

    • Effect sẽ chạy mỗi khi component render lại. Thường ít khi sử dụng vì có thể gây vòng lặp vô hạn hoặc hiệu năng kém.
  2. Mảng phụ thuộc rỗng: useEffect(() => { ... }, []);

    • Effect chỉ chạy một lần duy nhất sau lần render đầu tiên của component (tương tự như componentDidMount trong class components). Thường dùng để fetch dữ liệu ban đầu, thiết lập listeners global, v.v.
  3. Mảng phụ thuộc với các giá trị: useEffect(() => { ... }, [prop1, state1]);

    • Effect sẽ chạy sau lần render đầu tiên VÀ mỗi khi giá trị của prop1 hoặc state1 thay đổi. Đây là cách phổ biến nhất để đảm bảo effect chạy lại khi các dữ liệu mà nó phụ thuộc vào bị cập nhật.
Hàm Cleanup trong useEffect

Nhiều side effects cần được "dọn dẹp" khi component unmount hoặc khi effect chạy lại. Ví dụ: hủy bỏ một subscription, xóa một timer, loại bỏ một event listener. Để làm điều này, useEffect cho phép bạn trả về một hàm cleanup từ effect function.

useEffect(() => {
  console.log('Effect chạy!');

  // Thiết lập một timer
  const timerId = setInterval(() => {
    console.log('Timer tick...');
  }, 1000);

  // Hàm cleanup
  return () => {
    console.log('Cleanup chạy!');
    // Xóa timer khi component unmount hoặc effect chạy lại
    clearInterval(timerId);
  };
}, []); // Mảng rỗng để effect chỉ chạy một lần
  • Hàm cleanup sẽ chạy trước khi effect chạy lại (trừ lần chạy đầu tiên) và khi component bị unmount.
TypeScript và useEffect: Đảm bảo An toàn trong Side Effects

TypeScript không thay đổi cú pháp của useEffect một cách trực tiếp như useState. Tuy nhiên, nó đóng vai trò quan trọng trong việc đảm bảo các hàm và dữ liệu bạn sử dụng bên trong effect function là đúng kiểu.

Ví dụ: Fetching Data với TypeScript

Khi fetch data, chúng ta thường làm việc với dữ liệu có cấu trúc. TypeScript giúp đảm bảo dữ liệu nhận được từ API khớp với kiểu dữ liệu chúng ta mong đợi.

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

interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

function PostViewer({ postId }: { postId: number }) {
  const [post, setPost] = useState<Post | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);
    setError(null);
    setPost(null); // Reset post khi postId thay đổi

    const fetchPost = async () => {
      try {
        const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: Post = await response.json(); // Ép kiểu dữ liệu nhận được
        setPost(data);
      } catch (err: any) { // Bắt lỗi và ép kiểu
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPost();

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

  if (isLoading) return <p>Đang tải bài viết...</p>;
  if (error) return <p style={{ color: 'red' }}>Lỗi: {error}</p>;
  if (!post) return <p>Không tìm thấy bài viết.</p>;

  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </div>
  );
}

Trong ví dụ này:

  • Chúng ta định nghĩa interface Post để mô tả cấu trúc của dữ liệu bài viết.
  • useState<Post | null>(null): Trạng thái post được khai báo là Post hoặc null.
  • const data: Post = await response.json();: Khi nhận được dữ liệu từ API, chúng ta ép kiểu nó sang Post. Nếu cấu trúc dữ liệu thực tế không khớp với interface Post, TypeScript (và các công cụ linting như ESLint với plugin TypeScript) có thể cảnh báo bạn, giúp bạn phát hiện sai sót sớm.
  • catch (err: any): Lỗi trong JavaScript có thể có nhiều dạng, sử dụng any ở đây là một cách để xử lý nhanh, nhưng trong các ứng dụng lớn, bạn có thể muốn định nghĩa kiểu lỗi cụ thể hơn hoặc kiểm tra kiểu runtime.
  • Mảng phụ thuộc [postId]: Đảm bảo effect chạy lại mỗi khi giá trị của postId thay đổi, cho phép chúng ta tải bài viết mới khi người dùng chọn bài khác.

Kết hợp useState và useEffect: Sức mạnh Tổng hợp

useStateuseEffect thường hoạt động song song. useState quản lý dữ liệu mà UI hiển thị, trong khi useEffect thực hiện các tác vụ (như gọi API) để cập nhật dữ liệu đó dựa trên những thay đổi về trạng thái hoặc props.

Ví dụ: Một component hiển thị danh sách người dùng. Người dùng có thể nhập tên vào một ô input để tìm kiếm.

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

interface User {
  id: number;
  name: string;
}

function UserSearch() {
  const [searchTerm, setSearchTerm] = useState<string>('');
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Bỏ qua search nếu searchTerm rỗng để tránh fetch không cần thiết
    if (!searchTerm) {
      setUsers([]);
      return;
    }

    setIsLoading(true);
    setError(null);

    // Hàm giả lập gọi API tìm kiếm người dùng
    const fetchUsers = async () => {
      try {
        // Thay thế bằng fetch API thực tế
        console.log(`Đang tìm kiếm người dùng với từ khóa: ${searchTerm}`);
        const response = await fetch(`https://jsonplaceholder.typicode.com/users?name_like=${searchTerm}`); // Sử dụng jsonplaceholder cho ví dụ
         if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: User[] = await response.json();
        setUsers(data);
      } catch (err: any) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    };

    const debounceFetch = setTimeout(() => {
        fetchUsers();
    }, 500); // Debounce 500ms để tránh gọi API quá nhanh khi người dùng đang gõ

    // Cleanup: Hủy bỏ timer nếu searchTerm thay đổi trước khi fetchUsers được gọi
    return () => clearTimeout(debounceFetch);

  }, [searchTerm]); // Effect chạy lại mỗi khi searchTerm thay đổi

  return (
    <div>
      <h1>Tìm kiếm người dùng</h1>
      <input
        type="text"
        placeholder="Nhập tên người dùng..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />

      {isLoading && <p>Đang tìm kiếm...</p>}
      {error && <p style={{ color: 'red' }}>Lỗi: {error}</p>}

      {!isLoading && !error && users.length > 0 && (
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
       {!isLoading && !error && users.length === 0 && searchTerm && (
            <p>Không tìm thấy người dùng nào với từ khóa "{searchTerm}".</p>
       )}
        {!isLoading && !error && users.length === 0 && !searchTerm && (
            <p>Nhập tên để bắt đầu tìm kiếm.</p>
       )}
    </div>
  );
}

Trong ví dụ này:

  • useState<string>('') cho searchTerm: Quản lý giá trị nhập liệu của người dùng.
  • useState<User[]>([]): Quản lý danh sách người dùng tìm được.
  • useEffect(() => { ... }, [searchTerm]): Effect này phụ thuộc vào searchTerm. Mỗi khi người dùng gõ (và searchTerm thay đổi), effect sẽ chạy lại.
  • Bên trong useEffect, chúng ta thực hiện logic tìm kiếm (ở đây là giả lập hoặc gọi API thật).
  • Chúng ta sử dụng setTimeoutclearTimeout (hàm cleanup) để thực hiện debounce. Điều này nghĩa là API tìm kiếm chỉ được gọi sau khi người dùng dừng gõ trong 500ms, giúp giảm tải cho server.
  • Kết quả tìm kiếm được cập nhật vào trạng thái users bằng setUsers(data).
  • Component render lại dựa trên trạng thái users, isLoading, và error.

Ví dụ này thể hiện rõ cách useState (quản lý trạng thái nhập liệu và kết quả) và useEffect (thực hiện tác vụ tìm kiếm phụ thuộc vào trạng thái nhập liệu) phối hợp nhịp nhàng với sự hỗ trợ kiểu dữ liệu từ TypeScript.


Như bạn thấy, useStateuseEffect là hai hook cực kỳ mạnh mẽthiết yếu khi làm việc với React function components. Khi kết hợp chúng với TypeScript, chúng ta không chỉ viết code hoạt động đúng mà còn đảm bảo tính an toàn, rõ ràngdễ bảo trì hơn rất nhiều. Việc định nghĩa kiểu dữ liệu cho trạng thái và dữ liệu trong side effects là một bước đi quan trọng để xây dựng các ứng dụng Front-end vững chắc.

Comments

There are no comments at the moment.