HEEx dan LiveComponent

Template dengan pelacakan perubahan dan komponen stateful.

Daftar isi

HEEx adalah bahasa template milik Phoenix untuk menulis HTML dengan ekspresi Elixir, dan LiveComponent adalah bentuk komponen yang menyimpan statenya sendiri. Topik ini membahas interpolasi dan atribut dinamis pada HEEx, direktif :if dan :for, function component yang didefinisikan dengan attr, lalu LiveComponent yang memiliki state internal. Semuanya diperagakan pada dua skrip utuh yang merender hasilnya ke HTML, sehingga keluaran setiap fitur dapat dilihat secara nyata.

Interpolasi, atribut dinamis, dan pelolosan#

Template HEEx ditulis di dalam sigil ~H, dan dua sintaks interpolasi tersedia di dalamnya. Bentuk kurung kurawal {@hitung} adalah sintaks modern, sedangkan <%= @hitung %> adalah bentuk lama yang setara dan masih banyak digunakan. Keduanya mengeksekusi satu ekspresi Elixir lalu menulis hasilnya ke keluaran HTML, dan keduanya melakukan pelolosan (escape) secara otomatis, yaitu mengubah karakter <, &, dan tanda kutip menjadi entitas HTML sebelum keluaran dikirim.

Pelolosan otomatis adalah fitur keamanan yang berjalan tanpa pengaturan apa pun. Nilai assigns yang berisi <b>diperbarui otomatis</b> dirender sebagai teks &lt;b&gt;diperbarui otomatis&lt;/b&gt;, sehingga data pengguna tidak pernah dapat menyisipkan tag HTML atau skrip ke halaman. Apabila HTML mentah memang diinginkan, nilainya harus dibungkus Phoenix.HTML.raw/1 secara sengaja, dan keputusan itu menjadi tanggung jawab eksplisit penulisnya.

Atribut juga dapat ditulis secara dinamis. Bentuk id={dom_id} menyetel nilai atribut dari ekspresi Elixir, sedangkan bentuk class={["kartu", @nilai >= 75 && "tinggi"]} menggabungkan daftar kelas dan melewatkan elemen yang bernilai false atau nil. Direktif :for mengulang elemen untuk setiap item koleksi, dan :if merender elemen hanya ketika kondisinya benar:

html Contoh: atribut dinamis dan direktif dalam HEEx
<div class={["kartu", @nilai >= 75 && "tinggi"]}>
  <span>{@nama}</span>
  <strong>{@nilai}</strong>
</div>
<p :if={Enum.empty?(@siswa)}>belum ada nilai yang tercatat</p>
<li :for={s <- @siswa}>{s.nama}</li>

Function component#

Function component adalah fungsi biasa yang menerima assigns dan mengembalikan template HEEx, dengan deklarasi attr di atas tubuh fungsinya. Deklarasi attr :nama, :string, required: true menetapkan kontrak atribut, dan pemanggilan yang tidak mengirim atribut wajib tertangkap sebagai error pada saat kompilasi. Bentuk pemanggilannya menyerupai tag HTML, yaitu <.kartu nama="Ali" nilai={85} /> untuk komponen yang didefinisikan pada modul yang sama, atau <Komponen.kartu nama="Ali" nilai={85} /> untuk komponen yang didefinisikan pada modul lain.

Function component tidak menyimpan state apa pun. Setiap render memanggil ulang fungsi tersebut dengan assigns terbaru, sehingga komponen ini hanya tepat untuk bagian antarmuka yang nilainya sepenuhnya ditentukan oleh masukannya, misalnya kartu nilai, tombol, atau baris tabel. Kebutuhan state internal ditangani LiveComponent.

Skrip utuh: papan nilai dengan kartu#

Skrip berikut menyusun papan nilai siswa dari function component kartu. Direktif :for mengulang kartu untuk tiap siswa, atribut class menyusun daftar kelas secara dinamis, :if menyiapkan pesan untuk daftar kosong, dan interpolasi @catatan memperlihatkan pelolosan otomatis. Render kedua mengulang template dengan daftar siswa yang dikosongkan agar cabang :if terlihat:

elixir heex.exs
# heex.exs — interpolasi, atribut dinamis, :for/:if, dan function component dalam HEEx
Mix.install([{:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

defmodule PapanLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    siswa = [
      %{nama: "Ali", nilai: 85},
      %{nama: "Budi", nilai: 58}
    ]

    catatan = "<b>diperbarui otomatis</b> setiap & kali"
    {:ok, assign(socket, judul: "Papan nilai kelas 10A", siswa: siswa, catatan: catatan)}
  end

  # function component: fungsi yang menerima assigns dan mengembalikan template HEEx
  attr :nama, :string, required: true
  attr :nilai, :integer, required: true
  def kartu(assigns) do
    ~H"""
    <div class={["kartu", @nilai >= 75 && "tinggi"]}>
      <span>{@nama}</span>
      <strong>{@nilai}</strong>
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <h2>{@judul}</h2>
    <.kartu :for={s <- @siswa} nama={s.nama} nilai={s.nilai} />
    <p :if={Enum.empty?(@siswa)}>belum ada nilai yang tercatat</p>
    <p>{@catatan}</p>
    """
  end
end

Code.ensure_loaded(Phoenix.LiveViewTest)

{:ok, socket} = PapanLive.mount(%{}, %{}, struct(Phoenix.LiveView.Socket))
IO.puts(Phoenix.LiveViewTest.rendered_to_string(PapanLive.render(socket.assigns)))

# kondisi :if baru terlihat saat daftarnya kosong
IO.puts(Phoenix.LiveViewTest.rendered_to_string(PapanLive.render(Map.put(socket.assigns, :siswa, []))))
text Keluaran
<h2>Papan nilai kelas 10A</h2>
<div class="kartu tinggi">
  <span>Ali</span>
  <strong>85</strong>
</div><div class="kartu ">
  <span>Budi</span>
  <strong>58</strong>
</div>

<p>&lt;b&gt;diperbarui otomatis&lt;/b&gt; setiap &amp; kali</p>
<h2>Papan nilai kelas 10A</h2>

<p>belum ada nilai yang tercatat</p>
<p>&lt;b&gt;diperbarui otomatis&lt;/b&gt; setiap &amp; kali</p>

Semua perilaku yang dibahas muncul pada keluaran itu. Ali dengan nilai 85 memperoleh class="kartu tinggi", sedangkan Budi dengan nilai 58 memperoleh class="kartu " dengan satu spasi sisa, karena elemen false pada daftar kelasnya menyisakan slot kosong. Teks catatan tercetak dalam bentuk terloloskan, dan render kedua menghapus kedua kartu serta menampilkan pesan :if. Skripnya dapat dijalankan ulang dari terminal:

bash
# menjalankan skrip HEEx dari folder topik
elixir heex.exs
text Keluaran
<h2>Papan nilai kelas 10A</h2>
<div class="kartu tinggi">
  <span>Ali</span>
  <strong>85</strong>
</div><div class="kartu ">
  <span>Budi</span>
  <strong>58</strong>
</div>

<p>&lt;b&gt;diperbarui otomatis&lt;/b&gt; setiap &amp; kali</p>
<h2>Papan nilai kelas 10A</h2>

<p>belum ada nilai yang tercatat</p>
<p>&lt;b&gt;diperbarui otomatis&lt;/b&gt; setiap &amp; kali</p>

LiveComponent: komponen stateful#

LiveComponent didefinisikan dengan use Phoenix.LiveComponent dan menyimpan state di socket milik komponen, terpisah dari state LiveView pemanggilnya. Callback pembukanya adalah update/2 yang menerima assigns dari pemanggil, sedangkan handle_event/3 milik komponen menangani event yang ditargetkan kepadanya. Agar state tiap komponen dapat dibedakan, setiap pemanggilan wajib mengirim atribut id, dan dua pemanggilan dengan id berbeda menghasilkan dua instance yang statenya benar-benar terpisah.

Skrip berikut memuat komponen kotak penghitung, lalu membuat dua instance dari modul yang sama dan meneruskan dua event tambah hanya ke instance pertama:

elixir komponen.exs
# komponen.exs — LiveComponent dengan state lokal terpisah untuk dua instance
Mix.install([{:phoenix_live_view, "~> 1.1"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

defmodule KotakLive do
  use Phoenix.LiveComponent

  # update/2 adalah "mount" LiveComponent: dipanggil dengan assigns yang dikirim pemanggil
  def update(assigns, socket) do
    {:ok, assign(socket, id: assigns.id, label: assigns.label, hitung: 0)}
  end

  def handle_event("tambah", _params, socket) do
    {:noreply, update(socket, :hitung, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <div id={@id} class="kotak">
      <span>{@label}</span>
      <button phx-click="tambah">Tambah</button>
      <span>hitung: {@hitung}</span>
    </div>
    """
  end
end

Code.ensure_loaded(Phoenix.LiveViewTest)

# dua instance dari modul komponen yang sama, masing-masing dengan socketnya sendiri
{:ok, kotak_a} = KotakLive.update(%{id: "kotak-a", label: "Kelas A"}, struct(Phoenix.LiveView.Socket))
{:ok, kotak_b} = KotakLive.update(%{id: "kotak-b", label: "Kelas B"}, struct(Phoenix.LiveView.Socket))

# event hanya diteruskan ke instance kotak A, dua kali
{:noreply, kotak_a2} = KotakLive.handle_event("tambah", %{}, kotak_a)
{:noreply, kotak_a3} = KotakLive.handle_event("tambah", %{}, kotak_a2)

IO.puts(Phoenix.LiveViewTest.rendered_to_string(KotakLive.render(kotak_a3.assigns)))
IO.puts(Phoenix.LiveViewTest.rendered_to_string(KotakLive.render(kotak_b.assigns)))
text Keluaran
<div id="kotak-a" class="kotak">
  <span>Kelas A</span>
  <button phx-click="tambah">Tambah</button>
  <span>hitung: 2</span>
</div>
<div id="kotak-b" class="kotak">
  <span>Kelas B</span>
  <button phx-click="tambah">Tambah</button>
  <span>hitung: 0</span>
</div>

Keluaran menunjukkan sifat utama LiveComponent, yaitu state lokal per instance. Kedua kotak dibuat dari satu modul yang sama, tetapi dua event hanya mengubah kotak A menjadi hitung: 2, sedangkan kotak B tetap hitung: 0. Pada aplikasi nyata, pemisahan target event dinyatakan dengan atribut phx-target={@myself} pada elemen pemicunya, sehingga klik tombol satu kotak tidak pernah sampai ke kotak lain:

html Contoh: pemanggilan LiveComponent dan target event
<.live_component module={KotakLive} id="kotak-a" label="Kelas A" />
<.live_component module={KotakLive} id="kotak-b" label="Kelas B" />

Skrip LiveComponent dijalankan dengan cara yang sama seperti skrip lain pada modul ini:

bash
# menjalankan skrip LiveComponent dari folder topik
elixir komponen.exs
text Keluaran
<div id="kotak-a" class="kotak">
  <span>Kelas A</span>
  <button phx-click="tambah">Tambah</button>
  <span>hitung: 2</span>
</div>
<div id="kotak-b" class="kotak">
  <span>Kelas B</span>
  <button phx-click="tambah">Tambah</button>
  <span>hitung: 0</span>
</div>

Kesalahan umum#

Kesalahan pertama adalah lupa mengirim atribut id saat memanggil LiveComponent. Atribut itu adalah identitas instance bagi kerangka, sehingga pemanggilan tanpa id membuat komponen tidak dapat dilacak dan eventnya tidak dapat ditujukan. Konvensi yang baik adalah menggunakan kunci utama data, misalnya id={"kotak-#{s.kelas}"}, agar identitasnya stabil dari satu render ke render berikutnya.

Kesalahan kedua adalah mengira function component dapat menyimpan state. Assigns milik function component dibangun ulang dari assigns pemanggilnya pada setiap render, sehingga nilai yang dihitung di dalamnya hilang seketika. Apabila sebuah bagian antarmuka perlu menyimpan sesuatu di antara dua event, pindahkan ke LiveComponent atau naikkan statenya ke LiveView dan kirim kembali sebagai properti.

Kesalahan ketiga adalah mengira interpolasi yang memuat tag akan dirender sebagai HTML. Interpolasi {@catatan} selalu meloloskan isinya, sehingga string yang memuat tag tampil sebagai teks biasa, persis seperti baris &lt;b&gt;diperbarui otomatis&lt;/b&gt; pada keluaran skrip. Pembungkus Phoenix.HTML.raw/1 hanya digunakan untuk konten yang pembuatannya sepenuhnya terkendali, dan penggunaannya pada konten pengguna adalah celah keamanan.

Ringkasan#

  • HEEx menginterpolasi ekspresi Elixir dengan {...} atau <%= %>, meloloskan semuanya secara otomatis, serta menyediakan atribut dinamis dan direktif :if dan :for.
  • Function component adalah fungsi bertanda attr tanpa state, dipanggil sebagai <.nama /> atau <Modul.nama />, dan kontrak atributnya diperiksa saat kompilasi.
  • LiveComponent didefinisikan dengan use Phoenix.LiveComponent, memulai statenya di update/2, dan setiap instance wajib diberi id agar statenya terpisah.
  • Event LiveComponent ditujukan per instance lewat phx-target={@myself}, sehingga dua komponen sejenis tidak pernah saling menukar statenya.

Referensi#