State management gọn gàng

Tại sao cần đọc bài này?

  • Nâng cao trình độ làm state management
  • Code state một cách lean hơn
  • Có một cách khác khi tiếp cận bài toán trên frontend
 
Mình có cơ hội tiếp xúc với một vài bạn khi mới bắt đầu làm frontend và mình thấy mọi người hầu hết đều gặp phải cùng một vấn đề: Viết logic trong state-management quá phức tạp, dẫn tới code vừa khó hiểu lại còn khó debug.
Thường sau những lần review như vậy mình toàn là thằng đi xóa hết đống code đó để có một version lean hơn nên hy vọng qua bài này mọi người sẽ học được ít kỹ năng khi viết code state-management.
🗣
Context bài này mình sẽ chỉ focus vào hầu hết các Frontend framework hiện nay thôi nhé (React, Vue, Sveltve, Angular,...) Thường mình hay lấy ví dụ về React nhưng hấu hết mấy thằng khác sẽ na ná nhau nhé, vì state management thì không phụ thuộc vào framework mà 😉

UI = f(state)

Công thức huyền thoại cho Frontend Developer
Công thức huyền thoại cho Frontend Developer
State - the particular condition that someone or something is in at a specific time. Cambridge
Hiểu nôm na là trạng thái application của các bác sẽ được map qua UI tương ứng thông qua một hàm mapping. Vậy state management gọn gàng (từ giờ gọi là clean state management) nghĩa là việc thiết kế state trong application một cách gọn gàng để:
  • Mapping qua UI dễ hơn 😮‍💨
  • Ít code hơn ⇒ ít bug hơn 🐹
  • Ít code hơn ⇒ dễ chỉnh sửa hơn 😌

Khi nào state change?

Để viết clean state, đầu tiên phải tìm hiểu những thứ gì làm state thay đổi đã
notion image
Trong một application thì chỉ có 2 thứ có thể làm thay đổi state của bạn
  • Event từ user interactive với App
  • Event từ 3rd party (Trong đây mình định nghĩa là tất cả mọi thứ bắn event vào app mà không tới từ user là 3rd party, nó có thể là response từ backend, event từ websocket, hoặc là... cúp điện, rớt mạng)
 
Thông thường flow viết state mà mình hay thấy sẽ theo một cấu trúc như sau:
  1. Event tới (User hoặc 3rd party)
  1. Call đoạn code xử lý logic với event đó
  1. Lưu lại data đã xử lý vào state
  1. UI render theo state mới
Có thể ví dụ lại flow đó trong case: Filter lại list các task đã Done như sau
  1. User click vào button filter để filter task đã Done
  1. Nhận event filter task = "Done", filter các tast có status = "Done"
  1. Lưu danh sách đã filter vào state
  1. Render ra UI list task đã filter
 
Nếu mọi người tìm hiểu về bên làm data thì nó sẽ gọi flow này là: ETL - (Extract - Transform - Load). Bạn Extract data từ event, transform nó thành dạng data cần thiết, sau đó load lại vào state
ETL explained
ETL explained

Vấn đề là gì khi làm ETL ở frontend?

Khi có nhiều event kết hợp để cùng tạo ra một output UI.
 
Tưởng tượng với ví dụ Todo list ban đầu thì mình cần làm thêm những tính năng Search todo list. Lúc này state của chúng ta sẽ là
{ "source": [], // List todo raw "status": "Done" | "Undone", "keyword": "", "result": [] }
Vì hầu hết process build software sẽ theo Agile, nghĩa là tạo ra incremental theo từng iteration nên case làm xong todo list với filter Done/Undone sau đó thêm feature search todo là chuyện... thường ngày ở huyện ☺️ . Đừng trách ông nào sao không nói làm từ đầu đi cho lẹ, khỏi bug
Lúc này các bạn sẽ thấy nó khá đơn giản:
  1. Khi user input search keyword chỉ cần
  1. Lấy source data, filter theo status, sau đó filter lại theo keyword
  1. Sau đó lưu lại vào state
Bây giờ Todo list sẽ có 2 flow sau
notion image
Bạn nhận ra vấn đề gì ở đây không? Flow filter by status sẽ sai vì nó chỉ filter by status mà đánh rơi filter by keyword . Bạn là người mới vào project, bạn chỉ biết nhiệm vụ cần làm là add thêm flow search by keyword mà bạn không biết là các flow cũ cũng thay đổi output khi thêm một state mới này cũng là điều dễ hiểu! Bạn chỉ biết flow mình vừa làm: Search by keyword chạy ổn là được!
 
Rồi ok thấy bug rồi 🤡 vậy giờ gom lại thành một hàm là ngon chứ gì. Sau này có cần thêm filter by XYZ gì thì bỏ vào cái function đó là xong, bao mấy em QA vào đâm chọc 😎.
notion image
 
No, not that easy! Bây giờ thêm một case như này: Ngoài list todo đã được filter như yêu cầu bên trên, user cũng muốn có một thêm một list chỉ bao gồm những todo có priority là Important.
Mình sẽ gọi flow nãy giờ làm là flow 1 và flow cần làm tiếp theo là flow 2
notion image
Bây giờ flow code sẽ như trên hình. Bạn cần tính list mới lọc theo priority theo kết quả đã filter ở trên, ở đây có 2 cách:
  1. Chạy lại hàm transform ở flow 1 . Nhược điểm là hàm transform này phải chạy lại 2 lần
  1. Lấy kết quả ở State 1 để tính tiếp. Nhược điểm là app của bạn sẽ phải render lại 2 lần, lần đầu render theo flow đầu, sau đó có kết quả từ state 1 rồi mới run tiếp flow 2 dẫn tới render lần thứ 2 mới có được kết quả mong muốn
🚫
Đừng cố gắn Filter by Priority luôn vào flow 1 và đẻ luôn ra hai thằng state 1 và state 2 vì làm như vậy sẽ càng khiến cái app của bạn rối hơn 🙃 vì:
  • Code không tường mình để thể hiện tốt được flow của app
Flow expect sẽ được miêu tả: Lấy output của flow 1, filter theo priority để có flow 2 trong khi nếu nhìn vào code bạn gom cả việc xử lý chi tiết của flow 1 và xử lý chi tiết của flow 2 vào chung một function. Please don't
notion image
Một flow tốt là flow thể hiện rõ: - Input cần thiết là gì - Output mong muốn là gì - Khi nào thì flow này được chạy
 

Vấn đề một cách tổng quát hơn

notion image
Nếu nhìn một cách tổng quát, bạn đang handle event một cách độc lập và với mỗi một nhu cầu thể hiện ở UI, bạn lại lưu một state riêng cho nó. Việc làm như vậy khiến cho code của bạn khó mở rộng hơn, đồng thời cũng phải lưu nhiều state hơn như ví dụ hồi nãy mình có nói, mà càng nhiều code thì càng nhiều bug 🐞

Một cách tốt hơn với ELT (Extract - Load - Transform)

Bây giờ chúng ta thử đảo lại việc sắp xếp flow nhé. Thay vì transform rồi mới lưu vào state thì mình có thể làm ngược lại. Load vào state trước rồi mới transform để render ra UI
notion image
Lúc này các bác có thấy là state của chúng ta gọn hơn cả tỉ lần chưa? Bằng cách đổi thứ tự chạy flow, cụ thể là đấy transform ra bước cuối cùng rồi lấy output đó render ra UI luôn thì mình chả cần phải lưu gì cả.
const TodoList = () => { // Extract const [todos, setTodos] = useState([]); // Loaded from backend const [status, setStatus] = useState('Done'); const [keyword, setKeyWord] = useState(''); const [priority, setPriority] = useState('Important'); // Transform flow 1 const filteredTodos = useMemo(() => { return todos.filter(item => { if (status) { return item.status === status } return true; }).filter(item => { if (keyword) { return item.name.includes(item) } return true; }); }, [todos, state, keyword]); // Transform flow 2 const fitlertedTodosWithPriority = useMemo(() => { return filteredTodos.filter((item) => { if (priority) { return item.priority === priority } return true; }); }, [priority, filteredTodos]): return // Render your list here }
Quay lại với ví dụ ban đầu xem nó ntn nhé:
  • Flow 1, khi user có event filter by status hoặc filter by keyword, lưu lại event data status hay keyword vào state. Sau đó có một hàm transform với input là
    • Source data
    • Status
    • Keyword
Mỗi khi một trong 3 input state trên thay đổi, hàm render sẽ chạy lại ⇒ hàm transform sẽ tính ra kết quả mới ⇒ UI được cập nhật
  • Flow 2, khi user có event filter by priority. Sẽ có một hàm transform tương ứng với input là
    • Priority
    • Output của hàm transform ở flow 1
Rất tường minh mà còn không phải đánh đổi về performance đúng không?

FAQ

Performance? Mỗi lần app render thì phải chạy lại hàm transform à?
Như mình nói ở trên, state của app chỉ thay đổi khi có event được bắn ra. Do đó chuyện bạn chạy hàm transform khi có event rồi lưu lại kết quả vào state hay là lưu state rồi chạy transform thì chả khác gì cả, đều phải chạy lại transform. Vậy lỡ vì một event không liên quan khiến component re-render ⇒ Phải chạy lại hàm transform trong khi input của hàm transform đó không thay đổi mọe gì cả? 😪 Cách này mình thấy dễ fix mà, nếu dùng react thì bỏ vào useMemo với dependencies là list input của transform đó là được, dùng vue thì còn dễ hơn, bỏ vào computed là xong. Còn nếu dùng các thằng khác thì keyword để giải quyết là memorized function
Có scale được trong một application lớn không?
Có, scale mạnh là đằng khác! Các bác cứ hình dung source data là duy nhất - source of trust, component nào consume data sẽ có cách nhìn data đó theo một cách khác nhau.
notion image
Vd: Todo list là source of trust lấy từ backend lưu vô. Component Todo sẽ filter từ source of trust đó những task Undone. Component history sẽ filter từ source of trust đó những task trong quá khứ.
Vì vậy mỗi component sẽ có cách view data một cách khác nhau, và góc nhìn đó sẽ cùng với vòng đời của component, tạo ra khi component được tạo và xóa đi khi component bị destroy
isloading?
Hiểu đơn giản thì có 2 event sẽ làm thay đổi isLoading . Đầu tiên là user trigger request, và event còn lại là khi response trả về kết quả. Thằng này là dạng sub-state để thể hiện ra UI. Và chắc chắn kiểu này thì phải lưu rồi nhưng state kiểu này thường không liên quan gì tới các output UI khác nên mình khi vẫn ok khi bỏ vào state. Thực ra mình cũng không biết có cách nào khác để handle mấy case này
State normalization có tốt hơn?
Thực ra nó không liên quan lắm, state normalization là một cách để giải quyết redundant ở state. Nên nó kết hợp tốt với ELT. Lúc này flow sẽ là ETLT
  • Extract data từ API (Chạy 1 lần)
  • Transform - normalize data (Chạy 1 lần)
  • Load - Lưu vào state (Chạy 1 lần)
  • Transform - Tùy vào component consume state thế nào mà transform theo cách nó muốn

Tổng kết

Việc đổi từ ETL qua ELT sẽ khiến code của bạn lean hơn rất nhiều và việc này cũng thay đổi mindset của các bác về việc làm state: Từ nghĩ về cách xử lý logic khi có event tới sang việc tính toán output dựa trên những state hiện tại (Computed state)
Apply ELT siêu đơn giản, chỉ cần áp dụng câu thần chú.
🪄 Chỉ lưu các data tới từ event vào state, ngoài ra đừng lưu bất cứ thứ gì khác vào state.
 
 
P/S: Những bài kiểu này thì mình sẽ viết chủ yếu mô tả về concept nên sẽ gần như chả thấy code đâu cả. Nhưng nếu các bác cần code để hình dung một cách rõ hơn thì comment xuống dưới để mình bổ sung nhé. Đã bổ sung code
 

Loading Comments...

Follow me @cuthanh15