San Phan

Truyền HTML Element DOM từ component cha xuống component con

intersection observerdomuseRefreactjs

Bài viết đưa ra một vấn đề mà tôi gặp phải khi sử dụng ref để truy cập DOM và truyền nó xuống component con hoặc hook trong ReactJS. Vấn đề này khá phổ biến và khó để xác định được lỗi sai ở đâu. Tôi sẽ đưa ra giải pháp (phức tạp) ban đầu mình dùng để giải quyết và một giải pháp rất đơn giản khác (sau khi đã tìm hiểu kỹ hơn).

Vấn đề #

Tôi sẽ sử dụng lại ví dụ trong bài viết trước về Tìm hiểu Intersection Observer và ứng dụng trong ReactJS. Ta sẽ thử tạo lại hook useIntersectionObserver từ đầu nhé. Tôi sẽ không giới thiệu lại về Intersection Observer, các bạn có thể xem lại từ đường link phía trên.

Bàn luận một chút về hook useIntersectionObserver #

Hook này nhận đầu vào là các tham số cơ bản của một Intersection Observer, chúng ta sẽ có 4 tham số đầu vào chính, và trong phần bài viết này ta tập trung chủ yếu vào 1 tham số chính liên quan đến DOM Element. (chi tiết theo dõi lại bài viết về Tìm hiểu Intersection Observer và ứng dụng trong ReactJS)

  1. callbackFn: đây là một hàm callback, và nó sẽ được gọi khi xảy ra quá trình giao nhau (intersecting)
  2. target: đối tượng DOM element cần theo dõi sự giao nhau của nó với root element. Trong phần này ta tập trung chủ yếu vào nó.
  3. root: ở đây là element tổ tiên hoặc top level element (window.document) của target element. Trong vd này ta mặc định root = window.document mà trong phần lớn các trường hợp là thế.
  4. threshold: phần trăm sự giao nhau thì bắn ra sự kiện callbackFn.

Hook này không trả về giá trị nào cả, nó chỉ đơn giản là tạo một Intersection Observer và quan sát (observer).

Ví dụ áp dụng useIntersectionObserver #

Ta có một trang landing page với nhiều section khác nhau. Cái ta mong muốn là: khi người dùng cuộn chuột tới một section phía dưới thì một contact form sẽ xuất hiện và khi người dùng cuộn chuột lên thì tắt contact form đó

Mô tả sử dụng useIntersection

Khi người dùng cuộn chuột xuống section 6 thì contact form sẽ xuất hiện.

Bắt đầu từ sai lầm #

Khi bắt đầu quá trình xây dựng useIntersectionObserver tôi đã gặp vấn đề với DOM element sử dụng useRef.

Hook tôi định nghĩa như sau:

export type CallbackIntersectionObserverFnType = (
entry: IntersectionObserverEntry
) => void;

type FuncType = (args: {
callbackFn: CallbackIntersectionObserverFnType;
target: HTMLElement | null;
root?: HTMLElement;
threshold?: number | number[];
}) => void;

const useIntersectionObserver: FuncType = ({
callbackFn,
target,
root,
threshold,
}) => {
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target) {
callbackFn(target);
}
},
[callbackFn]
);

useEffect(() => {
const observer = new IntersectionObserver(handleObserver, {
root: root || null,
threshold,
});
if (target) observer.observe(target);
return () => {
if (target) observer.unobserve(target);
};
}, [callbackFn, handleObserver, root, target, threshold]);
};

Ok, sau khi viết xong thôi nghĩ nó sẽ work ngon lành, vì hook này quá là đơn giản luôn. Nào thử apply xem nào.

Tôi sẽ chỉ show đoạn sử dụng hook thôi nhé. Tập trung vào giá trị truyền vào target. Và giả sử ta có một ref box6Ref được referent tới section 6. Và root element ở đây là window.document do đó ta không truyền tham số root vào.

useIntersectionObserverWrong({
callbackFn: intersectionCallback,
target: box6Ref.current,
threshold: 0.1,
});

Nào bạn nghĩ nó sẽ hoạt đông không?

=> Nó sẽ vừa có và vừa không? Ngay khi implement xong nhấn save (trên editor) và quay sang trình duyệt (vì chức năng hot reload hoặc fast refresh trên CRA) thì nó work đấy. Nhưng khi reload lại trang (hay khởi tạo lại) thì nó lại không? => So magic!

Vì sao lại lỗi #

Khi gặp những trường hợp thế này, tôi thường debug bằng việc dùng console.log để check.

  1. check xem hàm callbackFn có được gọi hay không? Thực ra không cần check vì nếu nó vào thì phải work chứ.
  2. Tiến hành debug lại cái hook mình làm, sau một hồi thì phát hiện ra là giá trị target truyền vào hook đang bị null.

=> đó là nguyên nhân khiến nó không work.

Nhưng sao vẫn có trường hợp nó work #

Bạn cũng biết là một số framework như Next (tôi đang sử dụng) và CRA có tích hợp chức năng hot/reload hoặc fast refrsh. Như vậy, có trường hợp ban đầu khi khởi tạo box6Ref.current có giá trị null, nhưng sau khi hot reload thì trạng thái component được giữ nguyên, hook thay đổi chạy lại thì lúc box6Ref.current đã có giá trị và nó sẽ work.

Một điều cần nhớ về sử dụng ref và liên kết nó tới một dom element. #

Khi sử dụng ref, ban đầu ta luôn phải truyền một giá trị khởi tạo ban đầu (ví dụ với hook: useRef(<giá trị khởi tạo>)). Tất nhiên, nếu bạn không truyền giá trị ban đầu sẽ là null hoặc undefined. Nên khi khởi tạo ở ví dụ trên ta sẽ có box6Ref.current = null. Chỉ sau khi mà component được mount thì nó lúc này giá trị box6Ref.current mới có giá trị.

Nhưng tại sao khi component được mount thì giá trị box6Ref.current không tự động truyền xuống?

=> vì component hoặc hook được chạy lại khi mà state, props thay đổi. Ở đây giá trị box6Ref.current thay đổi thì hoàn toàn không thể khiến hook useIntersectionObserver chạy lại được. Nó cũng là lý do tại sao khi hot reload thì ví dụ trên lại work.

Giải pháp nào cho vấn đề này #

Tôi sẽ đi từ giải pháp phức tạp trước sau đó mới đến cách đơn giản và hiệu quả hơn rất nhiều.

Cách phức tạp #

Sau khi phát hiện ra vấn đề, giải pháp tôi nghĩ đến đầu tiên là: lưu giá trị dom element trong box6Ref.current vào state và truyền giá trị dom element trong state cho useIntersectionObserver

const box6Ref = React.useRef(null);
const [domElement, setDomElement] = React.useState(null);

React.useEffect(() => {
if (box6Ref.current) setDomElement(box6Ref.current);
}, []);

useIntersectionObserverWrong({
callbackFn: intersectionCallback,
target: domElement,
threshold: 0.1,
});

=> Vâng nhưng chả nhẽ mỗi lần sử dụng hook này là lại phải thêm một đống logic code như này sao.

=> giải pháp cho nó lại vô cùng đơn giản

Cách thức đơn giản #

Vâng, nó đến từ việc thay việc nhận target là kiểu HTMLElement ta sẽ thay nó bằng kiểu React.MutableRefObject. Áp dụng vào ví dụ này thì giá trị target truyền vào sẽ là box6Ref.

Hook tôi viết lại như sau:

export type CallbackIntersectionObserverFnType = (
entry: IntersectionObserverEntry
) => void;

type FuncType = (args: {
callbackFn: CallbackIntersectionObserverFnType;
target: React.MutableRefObject<any>;
root?: React.MutableRefObject<any>;
threshold?: number | number[];
}) => void;

const useIntersectionObserver: FuncType = ({
callbackFn,
target,
root,
threshold,
}) => {
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target) {
callbackFn(target);
}
},
[callbackFn]
);

useEffect(() => {
const observer = new IntersectionObserver(handleObserver, {
root: root?.current || null,
threshold,
});
const element = target?.current;
if (element) observer.observe(element);
return () => {
observer.unobserve(element);
};
}, [callbackFn, handleObserver, root, target, threshold]);
};

Và khi sử dụng đơn giản như này:

useIntersectionObserver({
callbackFn: intersectionCallback,
target: box6Ref,
threshold: 0.1,
});

=> Nó hoạt động một cách hoàn hảo.

Vì sao lại vậy: lý do là giá trị box6Ref sẽ luôn được lưu trữ trong bộ nhớ trong suốt toàn bộ chu kỳ của component (có nghĩa nó không bị khởi tạo lại) và giá trị box6Ref.current luôn trỏ về một địa chỉ ô nhớ. Vì vậy: const element = target?.current; giá trị element trong hàm useEffect của hook nhận được giá trị của DOM Element.

Tổng kết #

Tôi chỉ có một tổng kết trong toàn bộ bài viết là khi bạn muốn truyền giá trị DOM xuống hook hoặc component con thì hãy truyền toàn bộ giá trị ref xuống thay vì ref.current.

Và đây là ví dụ nếu bạn muốn kiểm chứng