+13

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 onSubmitonCancel. 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à isPhoneNumberInputautoCapitalize 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ả isLoadingisFinished 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí