Boolean: Bisa Jadi Bukan Teman Baikmu

Jun 6, 2020 22:42 · 765 words · 4 minute read #typescript #javascript #react #modelling

Boolean: Bisa Jadi Bukan Teman Baikmu
Image by PDPics from Pixabay

Studi Kasus

Singkat cerita, Jum’at kemarin salah satu rekan kerja saya sedang membuat fitur “read more” untuk konten dengan tinggi lebih dari 300px (tidak ada tombol “read more” jika kurang). Ketika tombol tersebut di-click, seluruh isi konten baru akan ditampilkan.

TypeScript
const ContentWrapper = () => {
  const [hasReadMoreBtn, setHasReadMoreBtn] = React.useState(false)
  const [isExpanded, setIsExpanded] = React.useState(false)
  const contentRef = React.useRef(null)

  React.useEffect(() => {
    if (contentRef.current.offsetHeight > 300) setHasReadMoreBtn(true)
  }, [])

  const expand = () => setIsExpanded(true)

  return (
    <div>
      <div ref={contentRef}>
        ...
      </div>
      {hasReadMoreBtn && <button onClick={expand}>read more</button>}
    </div>
  )
}

Cukup jelas. Namun ada masalah baru: setHasReadMoreBtn(true) dijalankan setelah render. Artinya, jika tinggi content ternyata melebihi 300px, flash of content pun kemungkinan terjadi: awalnya user melihat seluruh isi content, lalu sepersekian detik kemudian tombol “read more” baru muncul

flash of content

flash of content

Ngakalinnya, jangan tampilkan konten ke user sebelum tinggi konten diketahui.

TypeScript
const ContentWrapper = () => {
  const [hasReadMoreBtn, setHasReadMoreBtn] = React.useState(false)
  const [isExpanded, setIsExpanded] = React.useState(false)
  const [isReady, setIsReady] = React.useState(false)
  const contentRef = React.useRef(null)

  React.useEffect(() => {
    if (contentRef.current.offsetHeight > 300) setHasReadMoreBtn(true)
    setIsReady(true)
  }, [])

  const expand = () => setIsExpanded(true)

  return (
    <div style={{ visibility: isReady ? 'visible' : 'hidden' }}>
      <div ref={contentRef}>
        ...
      </div>
      {hasReadMoreBtn && <button onClick={expand}>read more</button>}
    </div>
  )
}

Masalah selesai. Tapi entah kenapa ada sesuatu yang mengganjal. It feels hacky. Masa iya harus butuh 3 buah state hanya untuk membuat fitur se-simple ini. Belum lagi, dengan kombinasi tiga buah boolean saja, ada banyak kemungkinan yang bisa terjadi. Beberapa diantaranya justru tidak valid.

KombinasiValiditas & Penjelasan
hasReadMoreBtn
isExpanded
isReady
Valid. State ini terjadi ketika tinggi konten melebihi 300px, dan user sudah meng-klik tombol “read more”
hasReadMoreBtn
isExpanded
isReady
Tidak valid. Bagaimana mungkin isExpanded sudah bernilai true sedangkan isReady masih bernilai false
hasReadMoreBtn
isExpanded
isReady
Valid. State ini terjadi ketika konten dengan tinggi >300px sudah tersedia namun user belum meng-klik tombol “read more”
hasReadMoreBtn
isExpanded
isReady
Valid. State ini terjadi ketika konten dengan tinggi >300px belum ditampilkan ke layar
hasReadMoreBtn
isExpanded
isReady
Tidak valid. isExpanded = true (tombol “read more” ketika sudah di-click) hanya mungkin terjadi bila hasReadMoreBtn juga bernilai true
hasReadMoreBtn
isExpanded
isReady
Tidak valid. Sama seperti di atas
hasReadMoreBtn
isExpanded
isReady
Valid. konten sudah disajikan dan tingginya tidak melebihi 300px
hasReadMoreBtn
isExpanded
isReady
Valid. konten belum disajikan dan tingginya tidak melebihi 300px

Pasti ada cara lain yang jauh lebih simple.

Solusi

Setelah me-review ulang behavior fitur ini dengan secarik kertas untuk dicorat-coret, saya mendapati bahwa sebenarnya requirement-nya cukup simple:

  1. Sembunyikan konten sampai tinggi konten diketahui (hidden)
  2. Jika tinggi konten kurang dari 300px, tampilkan seluruh isi konten (expanded)
  3. Jika lebih,
    • konten bisa di-expand dengan tombol “read more” (expandable)
    • Setelah tombol “read more” di-klik, tampilkan seluruh isi konten (expanded)

Dari list ini terlihat bahwa secara behavior, konten hanya bisa memiliki salah satu dari ketiga state berikut: Hidden, Expandable, atau Expanded. Kata kuncinya “atau”. As you might have guessed, solusi terbaik untuk masalah ini adalah dengan memodelkannya menggunakan union.

TypeScript
type ContentDisplay = 'hidden' | 'expandable' |'expanded'

const ContentWrapper = () => {
  const [contentDisplay, setContentDisplay] = React.useState<ContentDisplay>('hidden')
  const contentRef = React.useRef(null)

  React.useEffect(() => {
    setContentDisplay(contentRef.current.offsetHeight > 300
      ? 'expandable'
      : 'expanded'
    )
  }, [])

  const expand = () => setContentDisplay('expanded')
  const hasReadMoreBtn = contentDisplay === 'expandable'

  return (
    <div style={{ visibility: contentDisplay === 'hidden'
      ? 'hidden'
      : 'visible'
    }}>
      <div ref={contentRef}>
        ...
      </div>
      {hasReadMoreBtn && <button onClick={expand}>read more</button>}
    </div>
  )
}

Dengan pendekatan ini, kita telah mengeliminasi kemungkinan-kemungkinan state yang tidak valid sekaligus meningkatkan code readability. Kita bisa belajar dari kasus ini bahwa memang mudah memodelkan suatu behavior dengan boolean, namun saya rasa masih ada cara lain yang lebih tepat. Bila dalam memecahkan masalah ada dua atau lebih boolean yang terlibat yang saling berkaitan, mundurlah selangkah dan mulai pahami kembali requirement dari fitur yang ingin diimplementasi. Ambillah secarik kertas dan tulis semua kondisi yang mungkin muncul. Mungkin dengan enum atau union biasa solusimu jadi lebih simple 😉

Oh ya, saya sampai lupa ternyata saya juga pernah mengulas kasus serupa di artikel yang lain: Tentang Loading State… 🙃

Kesimpulan

Boolean bukanlah segalanya. Bahkan mungkin eksistensinya bisa tergantikan dengan enum atau untagged union.

TypeScript
enum Bool { true, false }

type Bool = 'true' | 'false'

Siapa yang tahu? Setidaknya, sudah ada yang memodelkannya dengan “enum”.

Sebagai penutup, video ini wajib ditonton bagi kamu yang ingin lebih mendalami tentang pemodelan business requirement ke dalam code:


Stay well, my friend!

Edit on