Bài 17.4: useRef với React-TypeScript

Chào mừng bạn quay trở lại với chuỗi bài viết về Lập trình Web Frontend!

Trong các bài học trước, chúng ta đã làm quen với React và một số React Hooks quan trọng như useState để quản lý state (trạng thái) của component, thứ khi thay đổi sẽ gây ra re-render và cập nhật giao diện người dùng. useEffect thì giúp chúng ta thực hiện các "side effects" như gọi API, tương tác với DOM, hoặc thiết lập subscriptions, thường chạy sau khi render.

Tuy nhiên, trong thế giới phát triển ứng dụng React, có những tình huống mà useState hay useEffect không hoàn toàn phù hợp hoặc là không phải là giải pháp tối ưu:

  1. Bạn cần truy cập trực tiếp vào một phần tử DOM cụ thể (ví dụ: để focus vào một input, đo kích thước, hoặc tích hợp với thư viện JavaScript không phải React).
  2. Bạn cần lưu trữ một giá trị nào đó mà giá trị này cần tồn tại (bền bỉ) qua các lần component re-render, nhưng việc thay đổi giá trị đó lại không cần thiết phải gây ra re-render của component.

Đây chính là lúc useRef tỏa sáng!

useRef: Người giữ giá trị "thầm lặng"

useRef là một React Hook cho phép bạn tạo ra một "ref" (tham chiếu). Về cơ bản, ref này là một object JavaScript thuần túy có một thuộc tính duy nhất là .current.

Điểm đặc biệt của ref được tạo bởi useRef là:

  • Nó tồn tại (bền bỉ) xuyên suốt vòng đời của component, qua các lần re-render.
  • Bạn có thể thay đổi trực tiếp giá trị của thuộc tính .current.
  • Quan trọng nhất: Việc thay đổi giá trị của .current sẽ KHÔNG gây ra re-render component.

TypeScript mang lại lợi ích gì khi sử dụng useRef? Nó giúp chúng ta định nghĩa rõ ràng kiểu dữ liệu cho giá trị mà ref đang giữ, đảm bảo an toàn và dễ đọc code hơn.

Cú pháp cơ bản:

import { useRef } from 'react';

// Với TypeScript, bạn cần cung cấp kiểu dữ liệu cho useRef
const myRef = useRef<KiểuDữLiệu>(giáTrịKhởiTạoBanĐầu);
  • KiểuDữLiệu: Kiểu dữ liệu của giá trị mà ref sẽ giữ (ví dụ: HTMLInputElement, number, string, null, undefined, v.v.).
  • giáTrịKhởiTạoBanĐầu: Giá trị ban đầu cho thuộc tính .current. Giá trị này chỉ được sử dụng một lần khi component được mount lần đầu.

Hãy cùng đi sâu vào hai trường hợp sử dụng chính của useRef.

Trường hợp 1: Truy cập và tương tác với các phần tử DOM

React khuyến khích mô hình khai báo (declarative) để xây dựng giao diện người dùng, nghĩa là bạn mô tả giao diện bạn muốn dựa trên state và props, và React sẽ lo phần cập nhật DOM thực tế. Tuy nhiên, đôi khi bạn vẫn cần "chạm" vào DOM thật để thực hiện các thao tác cấp thấp mà React không cung cấp API trực tiếp, ví dụ như:

  • Focus vào một input khi component mount.
  • Scroll đến một phần tử cụ thể.
  • Đo kích thước hoặc vị trí của một phần tử.
  • Tích hợp với các thư viện JavaScript bên ngoài cần một tham chiếu đến phần tử DOM.

Đây là lúc useRef được sử dụng để tạo ra một "ref object" và gắn nó vào một phần tử JSX thông qua thuộc tính ref.

Khi React render phần tử đó, nó sẽ gán phần tử DOM tương ứng vào thuộc tính .current của ref object mà bạn đã tạo.

Ví dụ 1: Tự động focus vào ô input khi component hiển thị

Giả sử bạn có một form đăng nhập nhỏ và muốn con trỏ nháy luôn xuất hiện ở ô nhập username ngay khi form hiện lên.

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

function FocusInput() {
  // Khai báo một ref để giữ tham chiếu đến phần tử input.
  // Kiểu dữ liệu là HTMLInputElement | null. Ban đầu là null vì phần tử DOM chưa được render.
  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    // useEffect chạy sau khi component được mount (với dependency array rỗng []).
    // Lúc này, inputRef.current đã được gán phần tử <input> DOM thực tế.
    if (inputRef.current) {
      inputRef.current.focus(); // Gọi phương thức focus() của phần tử input
    }
  }, []); // [] đảm bảo effect chỉ chạy một lần sau render đầu tiên.

  return (
    <div>
      <h2>Form Đăng nhập</h2>
      <input
        ref={inputRef} // Gắn ref object vào thuộc tính ref của phần tử input
        type="text"
        placeholder="Nhập username"
      />
      <button>Đăng nhập</button>
    </div>
  );
}

export default FocusInput;

Giải thích code:

  1. Chúng ta import useRefuseEffect.
  2. const inputRef = useRef<HTMLInputElement | null>(null);: Tạo một ref object tên là inputRef. Chúng ta khai báo kiểu dữ liệu sẽ là HTMLInputElement (kiểu cho phần tử <input> trong DOM) hoặc null (vì ban đầu khi component vừa render, phần tử DOM chưa kịp được gán vào ref, nên giá trị ban đầu là null).
  3. <input ref={inputRef} ... />: Chúng ta truyền inputRef vào thuộc tính ref của thẻ <input>. React sẽ tự động gán phần tử DOM thật sự của <input> vào inputRef.current sau khi render.
  4. useEffect(() => { ... }, []);: Sử dụng useEffect với dependency array rỗng ([]) để đảm bảo code bên trong chỉ chạy một lần sau lần render đầu tiên (khi component đã được mount và DOM đã sẵn sàng).
  5. if (inputRef.current) { inputRef.current.focus(); }: Bên trong useEffect, chúng ta kiểm tra xem inputRef.current có tồn tại (không phải null) hay không. Nếu có, tức là chúng ta đã có tham chiếu đến phần tử DOM, chúng ta gọi phương thức .focus() để đưa con trỏ nháy vào ô input.

Với TypeScript, việc khai báo kiểu dữ liệu HTMLInputElement | null cho inputRef giúp chúng ta biết chắc chắn .current sẽ là gì và truy cập các thuộc tính/phương thức an toàn hơn (ví dụ: inputRef.current?.focus()).

Trường hợp 2: Lưu trữ giá trị có thể thay đổi nhưng KHÔNG gây re-render

Đây là trường hợp sử dụng thứ hai, thường ít được nghĩ đến hơn so với truy cập DOM, nhưng lại rất mạnh mẽ. useRef cho phép bạn lưu trữ bất kỳ giá trị nào (số, chuỗi, object, boolean, hoặc thậm chí là các hàm/đối tượng phức tạp như timer IDs, socket connections, v.v.) mà không cần dùng đến useState.

Sự khác biệt cốt lõi với useState là:

  • Thay đổi giá trị của .current không kích hoạt lại quá trình render của component.
  • Bạn truy cập và thay đổi giá trị trực tiếp thông qua myRef.current = newValue; thay vì dùng hàm setter như setState(newValue);.

Khi nào bạn cần điều này?

  • Lưu trữ timer ID (setTimeout, setInterval) để có thể xóa nó sau này.
  • Lưu trữ kết nối Web Socket hoặc các resource cần được dọn dẹp.
  • Đếm số lần một sự kiện xảy ra chỉ cho mục đích nội bộ, không cần hiển thị số đếm đó trên giao diện người dùng ngay lập tức.
  • Lưu trữ giá trị "trước đó" (previous value) của một prop hoặc state.
  • Lưu trữ các cấu hình hoặc dữ liệu không ảnh hưởng đến UI.
Ví dụ 2: Đếm số lần button được click (cho mục đích nội bộ)

Hãy tạo một component có button. Mỗi lần click, chúng ta muốn tăng một bộ đếm, nhưng không muốn component re-render chỉ vì số đếm này thay đổi.

import React, { useRef } from 'react';

function ClickCounter() {
  // Khai báo một ref để lưu số lần click.
  // Kiểu dữ liệu là number. Giá trị ban đầu là 0.
  const clickCountRef = useRef<number>(0);

  const handleClick = () => {
    // Tăng giá trị của thuộc tính .current trực tiếp
    clickCountRef.current = clickCountRef.current + 1;
    // hoặc clickCountRef.current++;

    // In giá trị hiện tại ra console để kiểm tra (giá trị đã được cập nhật)
    console.log('Số lần click:', clickCountRef.current);

    // Lưu ý: Dù clickCountRef.current thay đổi, component này sẽ KHÔNG re-render.
    // Giao diện hiển thị sẽ không tự cập nhật số đếm này.
    // Nếu muốn hiển thị số đếm trên UI, bạn phải dùng useState.
  };

  return (
    <div>
      <h2>Bộ đếm thầm lặng</h2>
      <p>Kiểm tra console khi click button.</p>
      <button onClick={handleClick}>
        Click tôi!
      </button>
    </div>
  );
}

export default ClickCounter;

Giải thích code:

  1. const clickCountRef = useRef<number>(0);: Tạo một ref tên là clickCountRef với kiểu dữ liệu number và giá trị ban đầu là 0.
  2. const handleClick = () => { ... };: Định nghĩa hàm xử lý khi button được click.
  3. clickCountRef.current = clickCountRef.current + 1;: Bên trong hàm xử lý, chúng ta trực tiếp thay đổi giá trị của clickCountRef.current.
  4. console.log('Số lần click:', clickCountRef.current);: In giá trị mới ra console. Bạn sẽ thấy giá trị này tăng lên mỗi lần click.
  5. Tuy nhiên, vì việc thay đổi clickCountRef.current không gây ra re-render, đoạn văn bản hoặc bất kỳ phần nào của UI hiển thị phụ thuộc vào clickCountRef.current sẽ không được cập nhật. Đây là khác biệt lớn so với useState.
Ví dụ 3: Lưu trữ ID của Timer

Một trường hợp rất phổ biến là khi bạn sử dụng setTimeout hoặc setInterval và cần lưu lại cái ID mà các hàm này trả về để có thể dùng clearTimeout hoặc clearInterval sau này (thường là trong hàm cleanup của useEffect).

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

function TimerComponent() {
  // useState để hiển thị thời gian đã trôi qua (đây mới là thứ gây re-render)
  const [seconds, setSeconds] = useState<number>(0);

  // useRef để lưu trữ ID của interval. Kiểu là NodeJS.Timeout hoặc number (tùy môi trường JS)
  // và có thể là null ban đầu.
  const intervalIdRef = useRef<NodeJS.Timeout | number | null>(null);

  useEffect(() => {
    // Bắt đầu interval khi component mount
    intervalIdRef.current = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    // Hàm cleanup: sẽ chạy khi component unmount hoặc trước khi effect chạy lại
    return () => {
      // Sử dụng ID đã lưu trong ref để xóa interval
      if (intervalIdRef.current) {
        clearInterval(intervalIdRef.current as number); // Cần ép kiểu nếu TypeScript cảnh báo
      }
    };
  }, []); // [] đảm bảo effect chỉ chạy một lần khi mount và cleanup khi unmount

  return (
    <div>
      <h2>Timer đơn giản</h2>
      <p>Thời gian đã trôi qua: {seconds} giây</p>
      {/* Button để dừng timer (ví dụ thêm để minh họa cleanup) */}
      {/* <button onClick={() => {
          if (intervalIdRef.current) {
              clearInterval(intervalIdRef.current as number);
              intervalIdRef.current = null; // Reset ref nếu cần
          }
      }}>Stop Timer</button> */}
    </div>
  );
}

export default TimerComponent;

Giải thích code:

  1. useState được dùng để quản lý seconds - giá trị này thay đổi và cần gây re-render để cập nhật hiển thị thời gian trên UI.
  2. const intervalIdRef = useRef<NodeJS.Timeout | number | null>(null);: Chúng ta dùng useRef để lưu trữ cái ID mà setInterval trả về. ID này không cần hiển thị trên UI, nhưng cần được giữ lại để sử dụng trong hàm clearInterval. Kiểu dữ liệu có thể phức tạp một chút (NodeJS.Timeout | number | null) tùy thuộc vào môi trường JavaScript bạn đang dùng và định nghĩa kiểu của TypeScript. Chúng ta khởi tạo là null.
  3. Trong useEffect, chúng ta gọi setIntervalgán ID trả về vào intervalIdRef.current.
  4. Hàm cleanup của useEffect (được trả về) sẽ chạy khi component bị gỡ bỏ khỏi DOM hoặc trước khi useEffect chạy lại (trong trường hợp này chỉ chạy 1 lần nhờ []).
  5. Trong hàm cleanup, chúng ta sử dụng ID được lưu trong intervalIdRef.current để gọi clearInterval, ngăn timer tiếp tục chạy sau khi component không còn tồn tại, tránh memory leaks.

Việc sử dụng useRef ở đây cho phép chúng ta giữ giá trị interval ID bền bỉ qua các lần re-render (mỗi giây setSeconds gây re-render), mà không cần phải lưu nó trong state (vì nó không phải là dữ liệu cần hiển thị trực tiếp).

useRef vs useState: Chọn lựa thế nào?

Đến đây, bạn có thể thấy rõ sự khác biệt giữa useRefuseState:

Đặc điểm useState useRef
Mục đích chính Quản lý state ảnh hưởng đến UI, gây re-render. Quản lý tham chiếu DOM hoặc giá trị bền bỉ không gây re-render.
Gây re-render (khi giá trị thay đổi thông qua setter) Không (khi giá trị .current thay đổi)
Cách thay đổi Qua hàm setter (ví dụ: setCount(count + 1)) Trực tiếp qua thuộc tính .current (ví dụ: myRef.current = newValue)
Giá trị khởi tạo Dùng cho giá trị state ban đầu Dùng cho giá trị ban đầu của .current
Kiểu dữ liệu (TS) Kiểu của state (ví dụ: number, string, custom type) Kiểu của giá trị .current (`T nullcho DOM,T` cho giá trị bất kỳ)

Khi sử dụng useState: Khi dữ liệu của bạn cần hiển thị trên UIkhi thay đổi cần cập nhật lại giao diện.

Khi sử dụng useRef:

  • Khi bạn cần truy cập trực tiếp vào một phần tử DOM.
  • Khi bạn cần lưu trữ một giá trị bền bỉ qua các lần re-render nhưng không muốn việc thay đổi giá trị đó gây ra re-render.
  • Khi giá trị đó chỉ dùng cho mục đích "nội bộ" của logic component (ví dụ: timer ID, cờ hiệu, giá trị trước đó).

Comments

There are no comments at the moment.