React component code smells
Bài viết gốc: https://antongunnarsson.com/react-component-code-smells/
Code Smell là gì? code smell là thứ có thể chỉ ra vấn đề sâu hơn bên trong code nhưng không nhất thiết là lỗi. Đọc thêm trên Wikipedia
1. Too many props
Một component có quá nhiều props là dấu hiệu nên chia nhỏ component đó ra.
Vậy bao nhiêu thì coi là nhiều? Cái đấy còn tùy.
Bạn có thể thấy một component có 20 props và hài lòng nó vẫn đang làm việc ngon, giờ bạn muốn thêm một cái nữa vào danh sách props vốn đã dài, có một số điều cần xem xét:
Component này có làm nhiều thứ không?
Giống như functions, component chỉ nên làm tốt một việc, vì vậy luôn kiểm tra xem có thể chia component đó thành nhiều component con hay không?
Tôi có nên dùng composition?
Một mô hình tốt nhưng thường bị bỏ qua là tạo các compose components thay vì xử lý tất cả logic bên trong nó. Giả sử chúng ta có một component như sau:
<ApplicationForm
user={userData}
organization={organizationData}
categories={categoriesData}
locations={locationsData}
onSubmit={handleSubmit}
onCancel={handleCancel}
...
/>
Nhìn vào các props của component này, chúng ta có thể thấy rằng tất cả chúng đều liên quan đến chức năng của component, nhưng vẫn còn chỗ để cải thiện điều này bằng cách chuyển thành một số component con thay thế:
<ApplicationForm onSubmit={handleSubmit} onCancel={handleCancel}>
<UserField user={userData} />
<OrganizationField organization={organizationData} />
<CategoryField categories={categoriesData} />
<LocationsField locations={locationsData} />
</ApplicationForm>
Giờ đây, chúng ta đã đảm bảo rằng ApplicationForm
chỉ xử lý việc onSubmit
và onCancel
. Các component con có thể xử lý mọi thứ liên quan đến phần của chúng trong bức tranh lớn hơn. Đây cũng là một cơ hội tuyệt vời để sử dụng React Context cho việc giao tiếp giữa component cha và con.
Đọc thêm về compound components in React.
Tôi có đang gửi quá nhiều props config không?
Trong một số trường hợp, bạn nên nhóm các props lại với nhau thành một object, chẳng hạn như để hoán đổi cấu hình này dễ dàng hơn.
<Grid
data={gridData}
pagination={false}
autoSize={true}
enableSort={true}
sortOrder="desc"
disableSelection={true}
infiniteScroll={true}
...
/>
Tất cả các props ngoại trừ data
có thể được coi là config. Trong những trường hợp này, bạn nên thay đổi component Grid để nó nhận một options
gom các config ở trên.
const options = {
pagination: false,
autoSize: true,
enableSort: true,
sortOrder: 'desc',
disableSelection: true,
infiniteScroll: true,
...
}
<Grid
data={gridData}
options={options}
/>
2. Incompatible props
Tránh gửi các props không tương thích với nhau.
Ví dụ chúng ta tạo một component <Input />
chung chung nhằm mục đích xử lý type=text
, nhưng sau một thời gian chúng ta lại thêm chức năng cho nó với type=tel
. Việc triển khai có thể như sau:
function Input({ value, isPhoneNumberInput, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
return <input value={value} type={isPhoneNumberInput ? 'tel' : 'text'} />
}
Vấn đề xảy ra là isPhoneNumberInput
và autoCapitalize
không hợp cạ với nhau. Chúng ta đâu thể viết hoa số điện thoại.
Trong trường hợp này, giải pháp có lẽ là chia thành nhiều component nhỏ hơn. Nếu chúng ta có một số logic muốn chia sẻ, hãy chuyển nó sang custom hook:
function TextInput({ value, autoCapitalize }) {
if (autoCapitalize) capitalize(value)
useSharedInputLogic()
return <input value={value} type='text' />
}
function PhoneNumberInput({ value }) {
useSharedInputLogic()
return <input value={value} type='tel' />
}
Mặc dù ví dụ này hơi phức tạp, nhưng việc tìm kiếm các props không tương thích với nhau thường là một dấu hiệu tốt cho thấy bạn nên kiểm tra xem component có cần được chia nhỏ hay không.
3. Copying props into state
Đừng chặn dòng chảy của data bằng cách copy props vào state.
function Button({ text }) {
const [buttonText] = useState(text)
return <button>{buttonText}</button>
}
Bằng việc đặt props text
vào initialValue của useState khiến cho component này bỏ qua các update của props text
. Nếu props thay đổi thì component vẫn render với giá trị đầu tiên, đối với hầu hết các props, đây là hành vi không mong muốn, có thể làm cho component dễ bị lỗi hơn.
Một ví dụ thực tế hơn về điều này là khi chúng ta muốn lấy một giá trị mới nào đó từ một giá trị và đặc biệt nếu điều này đòi hỏi một số tính toán chậm. Ở ví dụ dưới đây, chúng ta chạy hàm slowFormatText
để định dạng props text
, điều này mất rất nhiều thời gian để thực thi.
function Button({ text }) {
const [formattedText] = useState(() => slowlyFormatText(text))
return <button>{formattedText}</button>
}
Một cách tốt hơn để giải quyết vấn đề này là sử dụng hook useMemo để ghi nhớ kết quả:
function Button({ text }) {
const formattedText = useMemo(() => slowlyFormatText(text), [text])
return <button>{formattedText}</button>
}
Bây giờ thì slowlyFormatText
chỉ chạy khi text
thay đổi và chúng ta không chặn update component.
Đôi khi chúng ta vẫn cần prop trong đó các thay đổi bị bỏ qua, ví dụ bộ chọn màu có các option cho sẵn, nhưng khi người dùng thay đổi thì chúng ta chưa muốn thay đổi ngay chỉ lưu tạm. Trong trường hợp này, việc sao chép prop vào state là hoàn toàn ổn, nhưng để biểu thị hành vi này của người dùng, chúng ta có thể phân tách ra thành initialColor hoặc defaultColor
Further reading: Writing resilient components by Dan Abramov.
4. Returning JSX from functions
Đừng trả về JSX từ các functions bên trong component.
Đây là một pattern phần lớn đã biến mất khi các function components trở nên phổ biến hơn, nhưng tôi vẫn thỉnh thoảng bắt gặp nó.
function Component() {
const topSection = () => (
<header>
<h1>Component header</h1>
</header>
)
const middleSection = () => (
<main>
<p>Some text</p>
</main>
)
const bottomSection = () => (
<footer>
<p>Some footer text</p>
</footer>
)
return (
<div>
{topSection()}
{middleSection()}
{bottomSection()}
</div>
)
}
Mặc dù điều này ban đầu có thể ổn, nhưng nó khiến bạn khó suy luận về code, không khuyến khích và nên tránh. Để giải quyết vấn đề này bạn nên chia các phần này thành các component con thay thế.
Hãy nhớ rằng khi bạn tạo một component mới, bạn không cần phải tách ra file mới. Đôi khi bạn nên giữ nhiều components trong cùng một file nếu chúng được kết hợp chặt chẽ với nhau.
5. Multiple booleans for state
Tránh sử dụng nhiều state boolean để thể hiện trạng thái của component
Khi viết một component và mở rộng chức năng cho nó, bạn có nhiều state để cho biết component đang ở trạng thái nào. Ví dụ:
function Component() {
const [isLoading, setIsLoading] = useState(false)
const [isFinished, setIsFinished] = useState(false)
const [hasError, setHasError] = useState(false)
const fetchSomething = () => {
setIsLoading(true)
fetch(url)
.then(() => {
setIsLoading(false)
setIsFinished(true)
})
.catch(() => {
setHasError(true)
})
}
if (isLoading) return <Loader />
if (hasError) return <Error />
if (isFinished) return <Success />
return <button onClick={fetchSomething} />
}
Mặc dù về mặt kỹ thuật thì nó hoạt động tốt nhưng thật khó để lý giải về trạng thái của component, nó có thể xảy ra lỗi. Chúng ta có thể rơi vào trạng thái impossible state nếu chúng ta vô tình đặt cả isLoading
và isFinished
thành true cùng một lúc.
Cách tốt hơn để quản lý trạng thái đó là dùng enum
. Ở các ngôn ngữ khác thì enum là cách để khai báo một tập hợp các giá trị không đổi, vì enum không tồn tại trong javascript nên chúng ta có thế sử dụng string thay thế.
function Component() {
const [state, setState] = useState('idle')
const fetchSomething = () => {
setState('loading')
fetch(url)
.then(() => {
setState('finished')
})
.catch(() => {
setState('error')
})
}
if (state === 'loading') return <Loader />
if (state === 'error') return <Error />
if (state === 'finished') return <Success />
return <button onClick={fetchSomething} />
}
Làm theo cách này chúng ta đã gỡ bỏ trạng thái impossible state
và khiến cho component trở nên dễ hiểu hơn. Nếu bạn sử dụng TypeScript thì có thể khai báo như sau:
const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle')
6. Too many useState
Tránh sử dụng quá nhiều useState
trong component.
Một component có quá nhiều useState
giống như làm quá nhiều thứ trong đó, tốt hơn là tách ra làm nhiều component, tuy nhiên sẽ có những component phức tạp cần nhiều state.
function AutocompleteInput() {
const [isOpen, setIsOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [items, setItems] = useState([])
const [selectedItem, setSelectedItem] = useState(null)
const [activeIndex, setActiveIndex] = useState(-1)
const reset = () => {
setIsOpen(false)
setInputValue('')
setItems([])
setSelectedItem(null)
setActiveIndex(-1)
}
const selectItem = (item) => {
setIsOpen(false)
setInputValue(item.name)
setSelectedItem(item)
}
...
}
Chúng ta có function reset
để reset các state, function selectItem
để cập nhật một số state. Cả hai hàm này đều sử dụng khá nhiều setState
để thực hiện nhiệm vụ. Bây giờ, nếu chúng ta có thêm nhiều hành động khác phải cập nhật state, điều này trở nên khó để giữ cho không có lỗi trong thời gian dài. Trong những trường hợp này, sẽ có lợi khi quản lý state bằng một useReducer
const initialState = {
isOpen: false,
inputValue: "",
items: [],
selectedItem: null,
activeIndex: -1
}
function reducer(state, action) {
switch (action.type) {
case "reset":
return {
...initialState
}
case "selectItem":
return {
...state,
isOpen: false,
inputValue: action.payload.name,
selectedItem: action.payload
}
default:
throw Error()
}
}
function AutocompleteInput() {
const [state, dispatch] = useReducer(reducer, initialState)
const reset = () => {
dispatch({ type: 'reset' })
}
const selectItem = (item) => {
dispatch({ type: 'selectItem', payload: item })
}
...
}
Với việc sử dụng useReducer
chúng ta đã đóng gói logic quản lý state và chuyển sự phức tạp ra khỏi component. Điều này làm cho việc hiểu những gì đang diễn ra dễ dàng hơn, chúng ta đã tách UI và logic riêng biệt.
Cả useState và useReducer đều có ưu nhược điểm với các use case khác nhau, một trong các mục yêu thích của tôi với reducers đó là state reducer pattern của Kent C. Dodds.
7. Large useEffect
Tránh sử dụng các useEffect
lớn và làm nhiều việc. Nó khiến code khó tìm lỗi, cứ lòng vòng update.
Một sai lầm mà tôi đã mắc phải là đặt quá nhiều thứ vào một useEffect
.
function Post({ id, unlisted }) {
...
useEffect(() => {
fetch(`/posts/${id}`)
.then(/* do something */)
setVisibility(unlisted)
}, [id, unlisted])
...
}
useEffect
này không lớn lắm nhưng lại làm nhiều việc. Nếu id
thay đổi thì fetch bài viết, nếu unlisted
thay đổi thì setVisibility, tuy nhiên chỉ cần một trong hai dependencies thay đổi thì cả hai việc đều thực hiện.
Để dễ theo dõi chúng ta nên tách ra làm hai useEffect
với từng dependencies riêng biệt
function Post({ id, unlisted }) {
...
useEffect(() => { // when id changes fetch the post
fetch(`/posts/${id}`)
.then(/* do something */)
}, [id])
useEffect(() => { // when unlisted changes update visibility
setVisibility(unlisted)
}, [unlisted])
...
}
Làm cách này chúng ta đã giảm độ phức tạp của component xuống, dễ dàng suy đoán logic, giảm nguy cơ lỗi.
8. Wrapping up
Được rồi, tạm thế nhé! Hãy nhớ rằng những điều nêu trên không phải là quy tắc mà là dấu hiệu cho thấy điều gì đó có thể "sai". Bạn chắc chắn sẽ gặp phải những tình huống trên và muốn thực hiện chỉnh sửa lại.
Bạn muốn feedback hay suggest về code smells
khác? Tìm tôi trên Twitter!
9. Evaluating with zero
Giả sử bạn có một danh sách items và sẽ render nếu có ít nhất một phần tử.
const [items, setItems] = React.useState([])
<div>
{items.length && <ShoppingList items={items} />}
</div>
Tuy nhiên, khi items không có phần tử nào lại xuất hiện một số 0. Vì sao thể nhỉ?
Khi items.length = 0
là một giá trị falsy trong JS, kết hợp &&
cho ra kết quả là 0
Trong JSX thì 0 là một giá trị valid nên nó được build ra UI.
Cách sửa 1: sử dụng so sánh trả về giá trị boolean là true-false
<div>
{items.length > 0 && <ShoppingList items={items} />}
</div>
Cách sửa 2: trả về null nếu không có phần tử
<div>
{items.length ? <ShoppingList items={items} /> : null}
</div>
10. Mutating state
Giả sử cần thêm một phần tử, có thể bạn từng làm:
const [items, setItems] = useState(['apple', 'banana'])
function handleAddItem(value) {
items.push(value)
setItems(items)
}
Kết quả: thêm một phần tử thì không có phần tử nào được build ra UI.
Lý do: đoạn code này vi phạm nguyên tắc cốt lõi của React, giá trị của state không được thay đổi.
Cách sửa: cần tạo một mảng mới cho danh sách thay đổi rồi mới gán giá trị state, làm tương tự với object
function handleAddItem(value) {
const nextItems = [...items, value]
setItems(nextItems)
}
11. Not generating keys
Khi bạn render danh sách các phần tử:
<ul>
{items.map(item => (
<li>{item}</li>
)
</ul>
Lý do: Warning: Each child in a list should have a unique "key" prop.
Cách sửa: Bạn sẽ cần cung cấp ngữ cảnh để React phân biệt các phần tử, bằng cách cung cấp các key không trùng nhau.
<ul>
{items.map(item => (
<li key={item.id}>{item}</li>
)
</ul>
12. Missing whitespace
<p>
Welcome to blog!
<a href="/login">Log in to continue</a>
</p>
Kết quả: 2 text trên bị liền với nhau Welcome to blog!Log in to continue
Cách sửa: thêm khoảng trắng {' '}
vào giữa hai dòng.
<p>
Welcome to blog!{' '}
<a href="/login">Log in to continue</a>
</p>
Kết quả: Welcome to blog! Log in to continue
13. Accessing state after changing it
Chúng ta có biến count = 0
sau khi click thì biến này tăng thêm 1 nhưng khi log kết quả ra thì vẫn là 0
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
console.log(count)
}
Lý do: hàm setCount
là một asynchronous, tức nó không ngay lập tức thay đổi giá trị mà chỉ đang lên lịch sẽ update state này mà thôi.
Cách sửa: sử dụng một biến trung gian
function handleClick() {
const nextCount = count + 1
setCount(nextCount)
console.log(nextCount)
}
14. Returning multiple elements
Đôi khi, bạn sẽ cần trả về nhiều thành phần, ví dụ như một thẻ label và một input.
Nếu bạn viết kiểu như thế này:
return (
<label></label>
<input />
)
thì sẽ báo lỗi Adjacent JSX elements must be wrapped in an enclosing tag.
Lý do: JSX elements cần được bọc bởi cặp thẻ đóng mở.
Sửa lỗi: bạn có thể sử dụng thẻ div
để bọc như này nhưng như thế lại sinh ra thẻ div
thừa có thể gây ra lỗi giao diện
return (
<div>
<label></label>
<input />
</div>
)
Giải pháp hay hơn là sử dụng <React.Fragment></React.Fragment>
, hay viết tắt là <></>
, nó sẽ không sinh ra bất kì thẻ html nào cả
return (
<>
<label></label>
<input />
</>
)
15. Flipping from uncontrolled to controlled
Giả sử có input là email, khi thay đổi giá trị sẽ set lại state cho biến email
const [email, setEmail] = React.useState()
<input
id="email-input"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
Khi bạn nhập giá trị vào input sẽ gặp lỗi Warning: A component is changing an uncontrolled input to be controlled
Lý do: biến state email không có giá trị ban đầu nên nhận giá trị là undefined
. Và khi gán value={email}
mình đã bảo với React đây là một uncontrolled component
. Tuy nhiên, khi thay đổi state thì lại muốn nó hoạt động như một controlled component
nên sẽ gây lỗi.
Cách sửa: đảm bảo email có giá trị ban đầu không phải là undefined
const [email, setEmail] = React.useState('')
16. Missing style brackets
Trong HTML thì css inline viết kiểu này
<button style="color: red; font-size: 1em">Submit</button>
Nhưng trong JSX, thuộc tính style cần được bọc bởi dấu {}
, và các giá trị viết theo cú pháp của object. { color: 'red', fontSize: '1em' }
Và vì nó là một giá trị của thuộc tính style, nên cần thêm 1 cặp {}
để thể hiện điều đó.
<button style={{ color: 'red', fontSize: '1em' }}>Submit</button>
Nếu muốn rõ ràng hơn, bạn có thể tạo một biến riêng cho style rồi dùng:
const btnStyle = { color: 'red', fontSize: '1em' }
<button style={btnStyle}>Submit</button>
17. Async effect function
Giả sử bạn cần gọi một API trả về danh sách các items, bạn sử dụng useEffect
hook và await
như sau:
useEffect(() => {
const res = await fetch('/api/v1/items')
const json = await res.json()
setItems(json)
}, [])
Đoạn code trên sẽ gây lỗi vì await
chỉ được dùng với async
function. Cho dù bạn thêm async
vào như thế này thì sẽ vẫn bị lỗi:
useEffect(async () => {
const res = await fetch('/api/items')
const json = await res.json()
setItems(json)
}, [])
Lý do: async function
trả về một promise, mà useEffect
lại không xử lý được với promise nên cần chuyển ra hàm async riêng và gọi hàm trong useEffect
useEffect(() => {
// create async function
async function fetchItems() {
const res = await fetch('/api/items')
const json = await res.json()
setItems(json)
}
// invoke it
fetchItems()
}, [])
18. State updates aren't immediate
Chúng ta có biến count = 0
sau khi click thì biến này tăng thêm 3 nhưng kết quả vẫn chỉ tăng lên 1
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
Lý do: biến count
vẫn đang chỉ nhận giá trị 0 nên thực ra chỉ là setCount(0 + 1)
Cách sửa: sử dụng updater function
const [count, setCount] = useState(0)
function handleClick() {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
}
All rights reserved