Yêu React chẳng cần cớ, cần hiểu rõ setState() cơ !

Chuyện là mình có một anh bạn đang làm Mark-up. Dạo gần đây, anh ấy có hướng mở rộng Tech Stack và có tìm hiểu về ReactJS. Trong một lần trao đổi, anh ấy bảo mình:

- Cái setState() trong React kì cục quá. Nhiều lúc cứ bị "chậm một nhịp" ấy =)))

Sau đó gửi mình 1 demo và đúng là như vậy thật. Con đường chinh phục React của người anh có vẻ không dễ dàng lắm... Nói như bây giờ thì:

Trứng rán cần mỡ, bắp cần bơ,
Yêu React chẳng cần cớ, cần hiểu setState() cơ 🙂))

Trong bài viết này chúng ta hãy cùng người anh tìm hiểu setState() nhé !

Đối tượng

Bài viết chủ yếu hướng tới các bạn mới tiếp cận, đã và đang làm ReactJS nhưng còn băn khoăn về cơ chế hoạt động của setState() cũng như muốn có cái nhìn rõ nét hơn về React API này ^^

Nhấp một ngụm espresso và bắt đầu với state trong ReactJS trước nào !

State

Tại sao cần có State?

ReactJS cho phép chúng ta chia UI thành các component độc lập để tiện xử lý logic và dễ dàng tái sử dụng. Trong concept của ReactJS:

All React Components must act like pure functions with respect to their props.


Điều này có nghĩa là, React component sẽ không cố thay đổi props nhận được và sẽ luôn trả về cùng một kết quả với cùng một đầu vào.

Song, ứng dụng của chúng ta luôn cần phản hồi tương ứng với các tương tác như user actions, network responses cũng như xử lý một số logic, tính toán dữ liệu thay đổi. Lúc này thì state chính là giải pháp ^^

State contains private information for interactivity, data-handling over time within the component.

Khởi tạo

Thông thường, state sẽ có dạng một POJO (Plain Old Javascript Object). Trong Class Component, state được recommend khai báo trong constructor():

constructor() {
    this.state = {
        votes: 0,
        views: 98
    }
}

Sau khi khởi tạo và xử lý tính toán với các state này, ReactJS cho phép chúng ta cập nhật lại state thông qua setState() API.

setState() API

Cú pháp

this.setState(object);            // Type (1)            
this.setState(a_function);        // Type (2) 
this.setState(object, callback);  // Type (3) 

Cơ chế hoạt động

Theo trang chủ, khi setState() được gọi, ReactJS sẽ merge object được truyền qua hàm với state hiện tại. Sau đó, hàm render() của component sẽ chạy lại, UI trên browser được cập nhật tương ứng với state mới .


Merge ở đây có nghĩa là gì?

Giả sử mình cập nhật votes trong state được khởi tạo phía trên:

this.setState({ votes: 93 });

Sau câu lệnh này, ReactJS sẽ giữ nguyên views, chỉ thay đổi giá trị của votes thôi.

The merging is shallow


Tại sao không thay đổi trực tiếp state?
this.state = object;

Khi muốn thay đổi state, chúng ta KHÔNG trực tiếp thay đổi qua this.state (mà nên thông qua this.setState()) bởi vì điều này sẽ không làm cho component re-render và thường dẫn đến việc không nhất quán state.

Các vấn đề thường gặp với setState()

Async

Giả sử chúng ta có đoạn code:

// State khởi tạo: { votes: 0 }
this.setState({ votes: 0910 });
console.log(this.state.votes); // 0

Dù chúng ta đã log state ra ngay sau setState(), thế nhưng giá trị được in ra vẫn là state cũ. Phải chăng lệnh log đã được chạy trước khi quá trình state mutation thực hiện xong?

Theo Trang chủ của ReactJS, setState() là hàm bất đồng bộ, và dĩ nhiên là nó không thay đổi state xong ngay lúc đó mà sẽ tạo một pending state transition. Có nghĩa là, log trước và để setState() vào event loop tiếp theo. Chính vì vậy, việc chúng ta truy cập vào state sau khi gọi setState() thì có khả năng sẽ được trả về giá trị state hiện tại.

Đó là lý do giá trị được log ra là 0 mà chẳng phải 1 - đúng là "chậm một nhịp" như anh bạn mình đã đề cập trong câu chuyện trên.

Giải pháp

State của chúng ta cần một thời gian để cập nhật thay đổi. Và để đảm bảo quá trình state mutate thật sự đã thực hiện xong, hãy truy cập giá trị state trong một callback:

// State khởi tạo: { votes: 0 }
this.setState(
    { votes: 0910 },
    function () { console.log(this.state.votes)}; // 0910
);
// OR
this.setState(
    { votes: 0910 },
    () => console.log(this.state.votes) // 0910
);

Giải pháp này hữu ích trong trường hợp muốn thực hiện một số functions hoặc kiểm tra state đã được tính toán logic chính xác chưa sau khi được cập nhật (fully-updated state).


Bonus

Một cách khác để đảm bảo việc này là đặt nó trong componentWillUpdate() hoặc componentDidUpdate(). Song, khác với callback một chút, các functions này có thể không được chạy nếu như có sự ngăn chặn việc component re-render trong shouldComponentUpdate().

Hoặc nếu thấy cách này cồng kềnh, bạn có thể thử với async - await:

async ensureStateUpdate(){
    await this.setState({ votes: 0910 });
    console.log(this.state.votes);
}

Hãy tự mình kiểm tra xem cách này khả thi không nhé =))

setState() đồng bộ ? Tại sao không ?

Sau khi nhận được lời giải thích và giải pháp phía trên, anh bạn Markup của tôi thắc mắc tại sao ReactJS không để setState() là một hàm đồng bộ luôn, để cú pháp với callback vào làm gì cho dài dòng?

Hmm...

Hàm setState() thay đổi statecomponent sẽ re-render. Đây là một expensive operation và điều này có thể khiến gây ra vấn đề browser unresponsive hay một số ảnh hưởng tới performance khác.


Có lẽ bởi vì như vậy, setState() nên là một hàm bất đồng bộ để có thể tốt hơn cho trải nghiệm của người dùng 😽😽


Okayyy, chúng ta đi vào một vấn đề hay gặp với setState() nữa !

Multi-setState() in a batch

Xét một ví dụ, khi ta click vào button, một function được trigger có nội dung như sau:

// State khởi tạo: { votes: 0 }
this.setState({ votes: this.state.votes + 1});
this.setState({ votes: this.state.votes + 1});
this.setState({ votes: this.state.votes + 1});

Hàm này cập nhật 3 lần liên tiếp state với setState(). Bây giờ bạn thử đoán xem giá trị cuối cùng của this.state.value là bao nhiêu ?

Sẽ là 1, hay 2, nhưng chẳng phải là 3 như chúng ta mong đợi ! Lý do chính là:

ReactJS sẽ nhóm các batch gọi setState() (gộp các lần gọi setState() gần nhau) thành một lần cập nhật.


Giải pháp

Chúng ta truyền vào setState() một function:

// State khởi tạo: { votes: 0 }
this.setState(state => ({ votes: state.votes + 1}));
this.setState(state => ({ votes: state.votes + 1}));
this.setState(state => ({ votes: state.votes + 1})); // 3

Yeahhh, lần này thì this.state.votes = 3 rồi nè!
Bạn có thể xem demo chi tiết tại đây nhé ▷

Song, như đã nói ở trên thì propsstate được cập nhật theo cơ chế bất đồng bộ nên hãy tránh-tối-đa việc tính toán các state mới dựa vào các giá trị state cũ hay props nhé. Trường hợp cần thiết thì cú pháp setState(a_funtion) được recommend sử dụng để tránh một số lỗi logic không đáng có.

this.setState((state, props) => ({
    votes: state.votes + props.increment
}))

setState() in lifecycle methods

Lại nói về các giai đoạn trong lifecycle của ReactJS component:

  • Mounting: Component bắt đầu được tạo ra và lần-đầu-tiên-được-chèn-vào-DOM (lần render đầu tiên của Component)
  • Updating: Khi props/state thay đổi làm cho component re-render; giai đoạn này có được diễn ra hay không có thể kiểm soát qua các lifecycle methods.
  • Unmounting: Ngay trước khi Component bị loại bỏ ra khỏi DOM.

Khái quát là vậy, một câu hỏi đặt ra là chúng ta có thể gọi setState() trong bất kì lifecycle methods nào mà chẳng xảy ra side-effects nào không ?

Hãy cùng điểm qua nhé !

Lifecycle methods setState() is OK?
constructor() NO. Vì đây là hàm khởi tạo state.
componentWillMount() ⇒ componentDidMount()
render() NOT DIRECTLY. Vì nếu không thì sẽ tạo ra infinite loop.
componentDidMount() YES. Song, người dùng sẽ không nhìn thấy state ngay lập tức.
componentWillReceiveProps() YES.
shouldComponentUpdate() N/A.
componentWillUpdate() NO. Vì sẽ tạo ra infinite loop.
componentDidUpdate() YES. Song, cần được bọc trong điều kiện nào đó (chỉ chạy nếu thỏa mãn điều kiện); nếu không sẽ tạo ra infinite loop.
componentWillUnmount() NO.

Notes:

setState() trong componentDidMount() được xem là một anti-pattern (?)

Bạn đã từng setState() trong componentDidMount() và nhận ra state trên màn hình được cập nhật chậm một nhịp chưa ?

Để giải thích điều này, chúng ta có thể dựa vào lifecycle của ReactJS. Khi setState() được gọi, nó sẽ kích hoạt thêm một lần render() nữa (render() trong quá trình Moutingrender() trong quá trình Updating). Điều này có thể gây ra một số vấn đề về performance.

Song, việc này giúp chúng ta đảm bảo được một điều rằng data sẽ không được load trước khi quá trình khởi tạo xong, tránh các lỗi không đáng có, đặc biệt là đối với Modals hay các element cần được tính toán vị trí, size, ... của node trong DOM trước khi được render ra màn hình.


Infinite loop

Một vòng lặp vô hạn (infinite loop) có thể được tạo ra trong một vài trường hợp chúng ta sử dụng setState() không cẩn thận.

Một ví dụ cụ thể như một lần vô tình đặt setState() trực tiếp trong render(). setState() sẽ kích hoạt việc re-render, rồi lại setState(), lại re-render, cứ như vậy... và một vòng lặp vô hạn được tạo ra 😹😹

// ⇒ An infinite loop
render() {
    this.setState({ votes: 93 });
    return ( ... )
}

Để khắc phục điều này, chúng ta cần hiểu cơ chế hoạt động của setState()ReactJS lifecycle để có thể nắm rõ được luồng chạy của ứng dụng ^^

Kết

Như vậy là chúng ta đã cùng nhau điểm qua cơ chế hoạt động của setState() và những điều thú vị xung quanh nó rồi.

Hy vọng rằng bài viết này có thể giúp ích được các bạn đang tiếp cận với ReactJS cũng như người anh trong câu chuyện của mình, từ đó có thể hiểu về luồng của ứng dụng và kiểm soát được một số lỗi liên quan tốt hơn.

Cảm ơn các bạn đã đọc bài chia sẻ này. Tặng mình 1 upvote để có thêm động lực cho những bài viết sắp tới nhé 😺😺


Chúc các bạn cuối tuần vui vẻ ! Tiện ghé qua nhà mình chơi một chút rồi về !

Happy coding !


Reference: Medium posts of Bartosz & Hootsuite, Stackoverflow, Freecodecamp, My Blog.