Bài viết được sự cho phép của tác giả Lưu Bình An
Đây là một bài viết tương đối dài dòng về useEffect, bạn cần biết và đã đọc qua tài liệu về useEffect trên trang chính thức của React trước, và nếu chỉ thực sự cần biết sử dụng useEffect ra sao, bạn không cần đọc bài viết phân tách mổ xẻ sâu kiểu này.
Mỗi lần render là một giá trị Prop và State độc lập
Trước khi bắt đầu nói về useEffect
chúng ta cần nhắc lại quá trình render
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p> ...
<button onClick={() => setCount(count + 1)}></button>
</div>
);
}
Khác với Vue, nó không phải là một dạng data binding, watcher, proxy, nó chỉ là một giá trị thông thường.
const count = 42;
<p> {count} </p>;
Đầu tiên giá trị khởi tạo của count
sẽ =0. Khi chúng ta gọi setCount(1)
, React sẽ gọi lại component một lần nữa, với giá trị count
lúc này là 1
. Cứ vậy
// Lần đầu render
function Counter() {
const count = 0; // trả về bởi useState() // ...
<p>You clicked {count} times</p>;
// ...
}
// sau khi click, function này được gọi lại lần nữa
function Counter() {
const count = 1; // trả về bởi `useState() // ...
<p>You clicked {count} times</p>;
// ...
}
// sau khi click, function được gọi lại lần nữa
function Counter() {
const count = 2; // trả về bởi useState() // ...
<p>You clicked {count} times</p>;
// ...
}
Khi update một state, React gọi lại component, mỗi lần render như vậy, nó sẽ thấy một giá trị count
mới. Sau đó React sẽ update lại DOM tương ứng.
Vấn đề mấu chốt cần nắm là giá trị count
trong các lần render khác nhau là khác nhau.
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert(`You clicked on: ${count}`);
}, 3000);
}
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleAlertClick}>Show alert</button>
...
</div>
);
}
Chúng ta thực hiện các bước sau
- Bấm counter lên 3
- Bấm “Show alert”
- Bấm tiếp
Click me
cho counter lên 5 trước khi bị gọi timeout
Câu hỏi ở đây là nó sẽ alert ra 5 – giá trị cuối cùng, hay là 3 giá trị lúc chúng ta click
Chạy thử
Bạn có thấy kết quả quá vô lý?
Như đã nói ở trên, giá trị count
là hằng số trên mỗi lần render. Function của chúng ta được gọi nhiều lần, mỗi lần gọi như vậy giá trị count
bên trong là một số độc lập hoàn toàn với giá trị trước đó
Không phải đặc sản của React, viết dạng function như thế này bạn sẽ dễ hình dung hơn.
function sayHi(person) {
const name = person.name;
setTimeout(() => {
alert(`Hello, ${name}`);
}, 3000);
}
let someone = { name: "Dan" };
sayHi(someone);
someone = { name: "Yuzhi" };
sayHi(someone);
someone = { name: "Dominic" };
sayHi(someone);
Thế còn hàm xử lý event thì sao? cụ thể là hàm handleAlertClick
? Cũng như trên, hàm này là có các version khác nhau ở các lần render khác nhau.
Bài viết được quảng cáo là nói về useEffect
mà nãy giờ chưa đá động gì!
Quay lại với ví dụ từ trang chính thức của React
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
Câu hỏi là useEffect
đã làm cách nào để lấy được giá trị cuối cùng của count
?
Lẽ nào đó có một dạng “data binding” hay “watching” ở đây để update giá trị count
bên trong hàm effect? Hoặc giả React chơi chiêu dùng biến mutable bên trong component để luôn có được giá trị cuối?
Không hề!
Chúng ta đã biết: giá trị count
là hằng số cho các lần render, event handle cũng độc lập trên các lần render khác nhau, effect cũng vậy luôn.
Không phải giá trị count
thay đổi bên trong useEffect
bất biến, mà là useEffect
cũng bị thay đổi trên từng lần render.
// lần render đầu tiên
function Counter() {
// ...
useEffect(() => {
document.title = `You clicked ${0} times`;
});
// ...
}
// sau khi click
function Counter() {
// ...
useEffect(() => {
document.title = `You clicked ${1} times`;
});
// ...
}
// click thêm lần nữa
function Counter() {
// ...
useEffect(() => {
document.title = `You clicked ${2} times`;
});
// ..
}
Có thể mường tượng effect là một phần của kết quả lúc render
Giờ thử với setTimeout
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
...
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
...
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Nếu mà click vài lần với một khoảng thời gian bỏ nhỏ thì kết quả log ra là gì?
Bạn không chỉ nhận được 1 mà là một chuỗi các đoạn log ứng với số lần click.
Đương nhiên phải chạy như vậy mới đúng chứ, đâu có gì phải thắc mắc?
Bạn đã thử với this.state
trong class component chưa?
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`);
}, 3000)
}
Lý do? Giá trị this.state
bên trong class component là một mutation (có thể thay đổi).
Nếu luôn muốn lấy giá trị sau cùng bên trong effect, cách dễ nhất là dùng refs
function Example() {
const [count, setCount] = useState(0);
...
const latestCount = useRef(count);
...
useEffect(() => {
...
latestCount.current = count;
...
setTimeout(() => {
...
console.log(`You clicked ${latestCount.current} times`);
...
}, 3000);
});
}
function Greeting({ name }) {
return <h1 className="Greeting">Hello, {name}</h1>;
}
Nếu chúng ta render <Greeting name="Dan" />
, sau đó render <Greeting name="Luu" />
. Cuối cùng chúng ta luôn nhận được Hello, Luu
React luôn đồng bộ cục DOM với giá trị hiện tại của prop
và state
. Không cần phân biệt giữa mount
và update
khi render. Có thể hình dung effect cũng tương tự như vậy, useEffect
cho phép đồng bộ những phần không nằm trong React tree với giá trị của prop
và state
function Greeting({ name }) {
useEffect(() => {
document.title = "Hello, " + name;
});
return <h1 className="Greeting">Hello, {name}</h1>;
}
Câu thần chú cho việc này là: Quan trọng là đích đến, không phải quá trình
Chạy effect trên tất cả lúc chạy render sẽ không hay lắm, đôi khi có trường hợp lặp vô tận.
Trong quá trình re-render, React chỉ cập nhập đúng phần DOM đã thay đổi.
Ví dụ như
<h1 className="Greeting">Hello, Dan</h1>
Sang
<h1 className="Greeting">Hello, Luu</h1>
React sẽ thấy 2 object
const oldProps = { className: "Greeting", children: "Hello, Dan" };
const newProps = { className: "Greeting", children: "Hello, Yuzhi" };
Nó sẽ xác định được children
bị thay đổi và cần update, còn className
thì không, nó sẽ làm như sau
domNode.innerText = "Hello, Luu";
Chúng ta cũng muốn effect làm điều tương tự, khi re-render chỉ apply những update cần thiết
Ví dụ với component này
function Greeting({ name }) {
const [counter, setCounter] = useState(0);
useEffect(() => {
document.title = "Hello, " + name;
});
return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(count + 1)}></button>
</h1>
);
}
useEffect
không hề liên quan tới giá trị state counter
, gọi document.title
khi giá trị counter
thay đổi không phải là ý hay.
Đó là lý do tại sao chúng ta có thêm tham số dependency
(một mảng) khi dùng useEffect
useEffect(() => {
document.title = "Hello, " + name;
}, [name]); // deps
Dịch ra ngôn ngữ con người là thế này: “Tao biết React mày không phân biệt được sự khác nhau bên trong function, nên tao hứa là tao chỉ dùng đến name
bên trong function này thôi, và chỉ giá trị name
này update thì mày hả gọi nó”
Một là không nói dối, 2 là không nói dối nhiều lần
Đừng bao giờ lừa gạt React bằng cách đưa dependency không đúng cho nó, hậu quả nhãn tiền. Hợp lý, nhưng nhiều lập trình viên quen sử dụng class
sẽ cố tình qua mặt
function SearchResults() {
async function fetchData() {
// ...
}
useEffect(() => {
fetchData();
}, []);
// việc ntn được hôn? không phải lúc nào cũng đúng
// có cách viết tốt hơn
}
Bạn sẽ nghĩ là “Tao chỉ muốn chạy nó lúc mount thôi”. Nếu chúng ta chỉ định một dependency, tất cả giá trị bên trong component sử dụng bởi effect phải được khai báo cụ thể. Bao gồm prop, state, function
Đôi khi mà làm như vậy nó phát sinh lỗi. Thí dụ như gọi fetch data liên tục hoặc socket được tạo không cần thiết. Cách giải quyets là không xóa chúng khỏi dependency
Trước khi nói về cách giải quyết, chúng ta xem vấn đề ở đây là gì khi so sánh Dependency
Hậu quả của việc dối trá
Nếu mảng dependency chứa tất cả giá trị sử dụng trong useEffect
, React biết được khi nào thì re-run nó
useEffect(() => {
document.title = "Hello, " + name;
}, [name]);
Nhưng nếu chúng ta chỉ định []
, nó không re-run sau lần đầu tiên
useEffect(() => {
document.title = "Hello, " + name;
}, []); // thiếu name
useEffect(() => {
document.title = "Hello, " + name;
}, []); // Sai: không được phép bỏ qua thằng name
Rõ ràng là 2 thằng dependency không khác nhau, nên nó sẽ không chạy effect
Trong tình huống này, vấn đề khá là hiển nhiên, nhưng trực giác có thể đánh lừa bạn trong các tình huống khác, lấy ví dụ, chúng ta muốn giá trị counter
tăng đều sau mỗi giây. Với một class, trực giác sẽ mách bảo: “Set up cái interval một lần, rồi dứt tình vứt áo một lần”, kiểu như thế này, khi chuyển qua dùng useEffect
bạn sẽ nghĩ đến dùng []
cho mảng phụ thuộc “Tao chỉ muốn tình một đêm”, đúng không?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
Theo như lập luận rất hay gặp “danh sách phụ thuộc cho phép chúng ta chỉ định việc re-render effect khi nào”, và ở đây ta chỉ muốn trigger nó một lần vì nó là interval, nhưng tại sao lại có vấn đề ở đây?
Chúng ta đang muốn effect này chỉ chạy lần đầu tiên mà thôi, đưa vào dependencies là []
có vẻ hợp lý, React sẽ bỏ qua hết những lần sau, nhưng chúng ta đang lừa dối React, vì bên trong chúng ta có sử dụng giá trị count
, chúng ta có giá trị phụ thuộc mà không khai báo. Thực tế setCount()
sẽ gọi liên tục sau 1 giây, chứ không dừng lại sau lần gọi đầu tiên.
Ở lần render đầu tiên, count
= 0, vì thế setCount(count + 1)
ở lần render đầu tiên nghĩa là setCount(0+1)
, nhưng vì không re-run effect thêm lần nào nữa, chúng ta cứ gọi mãi setCount(0+1)
ở những lần tiếp theo
// state = 0
function Counter() {
// ...
useEffect(
// lần đầu
() => {
const id = setInterval(() => {
setCount(0 + 1); // luôn là setCount(1) }, 1000);
return () => clearInterval(id);
},
[] // không re-run );
// ...
}
// state = 1
function Counter() {
// ...
useEffect(
// không bao giờ chạy () => {
const id = setInterval(() => {
setCount(1 + 1);
}, 1000);
return () => clearInterval(id);
},
[]
);
// ...
}
Những con bug như thế này sẽ rất rất khó để mò ra được, vì thế hãy luôn thành thật với React, khai báo hết dependency đang có.
2 cách để thú thật với React về dependency
Nên chọn cách một, cách 2 chỉ áp dụng khi cần thiết
Cách 1: luôn là người trung thực, chính trực đạo đức hết mực, luôn khai báo đầy đủ thông tin bạn trai, bạn gái, ba má, chú bác nào bạn đang phụ thuộc cho cơ quan thuế
useEffect(() => {
const id = setInterval(() => {
etCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
Tuy nhiên thế này, khi giá trị count
thay đổi, cái interval của chúng ta sẽ bị xóa và đặt lại lần nữa sau những lần render, nó không phải là cái chúng ta mong muốn nó hoạt động như vậy
Cách 2 là thay đổi tư duy, giảm bớt anh trai nuôi, em gái nuôi không cần thiết
Chúng ta không nói xạo, chúng ta giảm bớt số lượng những thứ phụ thuộc cho việc re-run effect
Để làm được việc này, chúng ta phải hỏi bản thân: chúng ta dùng count để làm gì? Có vẻ như chúng ta chỉ dùng nó cho việc gọi hàm setCount
, chúng ta không thực sự cần giá trị count
nếu chúng ta biết được giá trị trước đó, trường hợp trên, chúng ta có thể không cần dùng đến giá trị count
mà dùng previous state
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
Chạy thử
Tính năng update của Google Docs
Khi nói về effect, định hướng lập trình chúng ta là đồng bộ hóa, có một khái niệm khá thú vị khi thực hiện đồng bộ hóa là chúng ta thường không đồng bộ toàn bộ nội dung. Lấy ví dụ như Google Docs, nó không thực sự truyền tải cả trang lên phía server, làm như vậy hiệu năng sẽ rất tệ, cái nó làm là gửi đi một thông tin chứa cái mà user đang muốn thực hiện.
Tốt nhất truyền đi thật ít thông tin từ effect (chỉ những thông tin cần thiết nhất) vào trong component. Hàm setCount(c => c + 1)
sẽ gửi đi ít thông tin hơn so với hàm setCount(count + 1)
đứng trên một khía cạnh nào đó vì nó không phụ thuộc giá trị hiện tại, sử dụng ít state nhất có thể để đạt được kết quả là một trong các nguyên lý chính của đợt cập nhập React với effect
Tuy nhiên không phải lúc nào cuộc sống cũng đơn giản với bạn như vậy, nếu chúng ta muốn tính toán giá trị của state mới dựa trên một prop, 2 giá trị state phụ thuộc lẫn nhau, setState
là không đủ. Chúng ta có người chị em hàng xóm tên useReducer
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === "tick") {
return state + step;
} else {
throw new Error();
}
}
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" });
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
return <h1>{count}</h1>;
}
Cách dùng useReducer
như vậy là một dạng cheat mode của hook, cho phép chúng ta bỏ qua các dependency ngầm khỏi effect, và chặn re-run không không cần thiết
Bài viết này vẫn còn, và nếu bạn vẫn còn muốn đào sâu hơn nữa, có thể tìm đọc bài viết gốc của Dan A Complete Guide to useEffect
Bài viết gốc được đăng tải tại Vuilaptrinh