Ada kelas bug frontend yang sangat mengganggu: tombol terlihat benar, tetapi kliknya salah setelah hydration. Secara visual, label, warna, dan posisinya tampak sesuai. Namun saat pengguna menekan tombol, yang terjadi bisa berbeda: handler tidak jalan, aksi lama yang terpanggil, tombol yang seharusnya disabled masih bisa ditekan sesaat, atau elemen yang terlihat seperti tombol ternyata belum punya perilaku yang konsisten.
Masalah ini umum pada aplikasi yang memakai SSR (Server-Side Rendering) lalu dilanjutkan dengan hydration di client, misalnya pada React dan Next.js. Akar masalahnya bukan sekadar “event belum terpasang”, tetapi kombinasi beberapa hal: markup awal dihasilkan server, event handler baru aktif saat hydration, state awal server dan client bisa berbeda, conditional rendering dapat bergeser, closure bisa usang, dan key atau id yang tidak stabil dapat membuat DOM terlihat sama tetapi identitas logikanya berubah.
If you’re a button, you have one job. Dalam konteks UX, tombol punya kontrak sederhana: tampilannya harus selaras dengan perilakunya. Begitu UI menyampaikan “klik saya untuk X”, tetapi hasilnya Y, kepercayaan pengguna langsung turun. Di sinilah reliability frontend sama pentingnya dengan estetika.
Apa yang sebenarnya terjadi saat hydration?
Pada SSR, server mengirim HTML awal agar halaman cepat tampil dan bisa diindeks. Namun HTML itu pada awalnya hanya struktur dan atribut. Pada framework seperti React, interaktivitas penuh baru aktif setelah JavaScript client memuat dan proses hydration selesai.
Hydration mencoba “menyambungkan” tree komponen di client ke DOM yang sudah ada dari server. Jika state awal, struktur elemen, atau identitas node berbeda antara server dan client, hasilnya bisa berupa:
- warning mismatch di console,
- node DOM dipakai ulang untuk elemen logika yang berbeda,
- handler terpasang ke elemen yang bukan niat semula,
- atribut seperti
disabled,aria-*, atau teks tombol berubah setelah hydration, - pengguna sempat berinteraksi pada jendela waktu ketika UI terlihat siap, tetapi state logikanya belum sinkron.
Inilah mengapa bug ini terasa aneh: pengguna melihat satu hal, tetapi runtime client menafsirkan hal lain beberapa milidetik kemudian.
Gejala umum tombol yang tampak benar tapi perilakunya salah
1. Tombol terlihat aktif, tetapi klik tidak melakukan apa-apa
Biasanya terjadi karena event handler belum terpasang, hydration gagal sebagian, atau elemen yang dipakai bukan <button> semantik melainkan <div> atau <a> yang menunggu JavaScript.
2. Tombol sempat bisa diklik lalu mendadak menjadi disabled
Server merender tombol aktif, tetapi di client state awal mengatakan seharusnya disabled. Pengguna yang cepat bisa menekan tombol pada jendela singkat sebelum state client menimpa tampilan awal.
3. Tombol memanggil aksi yang salah
Sering berkaitan dengan key list yang tidak stabil, sehingga DOM yang tampak sama dipakai ulang untuk item berbeda. Secara visual labelnya benar, tetapi handler atau data yang melekat bukan milik item tersebut.
4. Label tombol berubah setelah hydration
Misalnya server menampilkan “Simpan”, lalu client mengganti menjadi “Memproses...”. Jika transisi ini tidak deterministik, pengguna bisa menekan tombol pada fase yang tidak sesuai dengan state aslinya.
5. Klik bekerja di lokal, tetapi gagal di produksi
Ini sering terjadi karena perbedaan timing jaringan, SSR aktif di produksi, data loading berbeda, atau urutan render yang tidak identik antara mode development dan production.
Akar teknis utama
SSR menghasilkan markup awal, event handler baru aktif saat hydration
HTML dari server tidak membawa fungsi JavaScript. Tombol memang terlihat seperti tombol, tetapi aksi klik di client baru benar-benar hidup ketika bundle termuat dan framework selesai mengaitkan event.
Jika Anda membangun UI yang “terlihat siap” padahal logikanya belum siap, pengguna bisa berinteraksi terlalu dini. Ini bukan sekadar soal performa, tetapi soal kejujuran antarmuka.
State awal server dan client berbeda
Ini sumber bug paling umum. Server merender berdasarkan satu kondisi, client menghitung ulang berdasarkan kondisi lain. Contohnya:
- server tidak punya akses ke
localStorage, client punya, - server tidak tahu ukuran viewport, client tahu,
- server memakai data fallback, client memakai data terbaru,
- state awal bergantung pada waktu, locale, random value, atau environment browser.
Begitu hydration berjalan, UI yang semula tampak benar bisa berubah makna. Jika tombol “Checkout” seharusnya disabled ketika keranjang kosong tetapi server belum tahu isi keranjang client, maka markup awal bisa menyesatkan.
Conditional rendering mengubah struktur elemen
Jika di server Anda merender satu cabang, lalu di client cabang lain, React harus menyesuaikan node yang sudah ada. Bila perbedaannya halus, hasil visual bisa tampak mirip, tetapi asosiasi event dan identitas elemen bisa bergeser.
Contoh umum: menampilkan <button>Beli</button> di server, tetapi setelah hydration mengganti menjadi <button>Login dulu</button> atau bahkan elemen lain dengan posisi sama.
Disabled state tidak deterministik
disabled bukan atribut kosmetik. Ia mengubah perilaku interaksi, fokus, dan aksesibilitas. Jika status disabled dihitung berbeda di server dan client, Anda menciptakan kontradiksi langsung antara visual dan aksi.
Kesalahan yang sering terjadi adalah menghitung disabled dari state yang hanya ada di browser, padahal server merender tombol seolah aktif.
Closure usang (stale closure)
Tombol bisa terlihat benar, tetapi saat diklik menggunakan nilai state lama yang tertangkap di closure. Ini tidak selalu murni masalah hydration, tetapi sering terlihat setelah hydration ketika render pertama dan render sesudah sinkronisasi client menghasilkan referensi fungsi atau data yang berubah.
Akibatnya, tombol memanggil aksi untuk item lama, memakai flag lama, atau mengirim payload yang sudah tidak relevan.
Mismatch id atau key
Jika Anda merender list tombol dan memakai index sebagai key sementara urutan data bisa berubah antara server dan client, React dapat memakai ulang node DOM untuk item yang berbeda. Secara kasat mata semuanya tampak normal, tetapi klik pada “Hapus item A” bisa menargetkan item B.
Masalah serupa muncul jika id elemen dibuat dari nilai acak atau tidak stabil, lalu dipakai untuk asosiasi label, target, atau selector.
Contoh reproduksi pada React/Next.js
Contoh 1: state awal berbeda antara server dan client
Contoh berikut menunjukkan tombol yang tampak aktif dari SSR, tetapi sesudah hydration ternyata harus disabled karena state sebenarnya baru diketahui di browser.
import { useEffect, useState } from 'react';
export default function CheckoutButton() {
const [hasItems, setHasItems] = useState(true);
useEffect(() => {
const raw = window.localStorage.getItem('cart-count');
setHasItems(Number(raw || '0') > 0);
}, []);
return (
<button
type="button"
disabled={!hasItems}
onClick={() => {
console.log('checkout');
}}
>
Checkout
</button>
);
}Masalahnya: server tidak bisa membaca localStorage, jadi state awal true hanyalah tebakan. Akibatnya tombol tampil aktif pada HTML awal, lalu menjadi disabled setelah hydration.
Pendekatan yang lebih aman adalah membuat state awal deterministik, atau menunda interaksi sampai data client benar-benar diketahui.
import { useEffect, useState } from 'react';
export default function CheckoutButton() {
const [ready, setReady] = useState(false);
const [hasItems, setHasItems] = useState(false);
useEffect(() => {
const raw = window.localStorage.getItem('cart-count');
setHasItems(Number(raw || '0') > 0);
setReady(true);
}, []);
return (
<button
type="button"
disabled={!ready || !hasItems}
aria-busy={!ready}
onClick={() => {
if (!ready || !hasItems) return;
console.log('checkout');
}}
>
{ready ? 'Checkout' : 'Memuat...'}
</button>
);
}Pola ini bekerja karena server dan client sama-sama mulai dari state konservatif: belum siap untuk interaksi. Begitu data client tersedia, perilaku tombol baru dibuka dengan konsisten.
Contoh 2: key list tidak stabil
function ProductList({ products }) {
return (
<ul>
{products.map((product, index) => (
<li key={index}>
<button onClick={() => addToCart(product.id)}>
Beli {product.name}
</button>
</li>
))}
</ul>
);
}Ini terlihat biasa, tetapi key={index} berbahaya jika urutan products bisa berubah setelah hydration, misalnya karena sorting client-side, filter, atau data update. Gunakan key yang stabil:
function ProductList({ products }) {
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<button type="button" onClick={() => addToCart(product.id)}>
Beli {product.name}
</button>
</li>
))}
</ul>
);
}Dengan key stabil, React punya identitas yang benar untuk setiap item dan tidak sembarang memakai ulang node untuk item lain.
Contoh 3: conditional rendering yang bergantung pada browser
export default function ActionButton() {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
if (isMobile) {
return <button type="button" onClick={openSheet}>Buka Menu</button>;
}
return <button type="button" onClick={openDialog}>Buka Menu</button>;
}Server tidak punya window.innerWidth, sehingga cabang render server dan client bisa berbeda. Teks tombol sama, tetapi handler berbeda. Ini persis jenis bug yang membuat “tampilannya benar, kliknya salah”.
Solusinya adalah menghindari keputusan render awal yang bergantung pada API browser, atau memisahkan logika tersebut ke fase setelah mount dengan fallback yang netral.
Cara debug bug hydration pada tombol
1. Cari warning hydration di console
Jangan abaikan warning seperti mismatch text, attribute, atau tree structure. Walau gejalanya tampak kecil, warning ini sering menjadi petunjuk bahwa DOM awal dan tree client tidak identik.
2. Bandingkan HTML server dengan hasil setelah hydration
Lihat View Source atau respons HTML dari server, lalu bandingkan dengan DOM setelah aplikasi interaktif. Fokus pada:
- apakah elemen masih
<button>atau berubah menjadi elemen lain, - apakah atribut
disabled,type,aria-disabled, dandata-*berubah, - apakah teks tombol sama,
- apakah urutan list berubah.
3. Log state pada render server dan render client
Jika framework Anda mendukung logging terpisah untuk server dan client, catat nilai state awal yang dipakai untuk menentukan label, disabled, dan handler. Tujuannya bukan sekadar melihat “nilainya apa”, tetapi memastikan nilai yang memengaruhi interaksi identik saat render awal.
4. Periksa source state yang tidak tersedia di server
Audit semua penggunaan:
window,document,localStorage,sessionStorage,- ukuran viewport, media query yang dihitung di JavaScript,
- waktu saat ini, random number, locale browser,
- data user yang baru diketahui setelah mount.
Jika salah satu memengaruhi tombol, Anda punya kandidat penyebab mismatch.
5. Audit key dan identitas item list
Jika tombol berada di dalam daftar, pastikan key berasal dari identifier stabil, bukan index dan bukan nilai acak. Ini langkah penting ketika bug terlihat “klik item A, tetapi yang bekerja item B”.
6. Verifikasi closure dan dependensi hook
Periksa apakah callback tombol menangkap state lama karena memoization atau dependensi hook yang tidak lengkap. Hydration bisa memunculkan gejalanya lebih jelas, tetapi akar masalahnya tetap callback yang memakai data usang.
7. Uji interaksi sebelum dan sesudah hydration
Reproduksi sering lebih mudah jika Anda mensimulasikan koneksi lambat atau throttle CPU. Dengan begitu Anda bisa melihat jendela waktu ketika HTML sudah muncul tetapi hydration belum selesai. Jika pengguna bisa menekan tombol pada fase itu, desain interaksinya perlu diperbaiki.
Praktik implementasi yang lebih aman
Gunakan semantic button
Untuk aksi, gunakan <button>, bukan <div> yang diberi onClick. Ini penting untuk keyboard, fokus, disabled state, dan perilaku bawaan yang konsisten. Jika tombol berada dalam form, tetapkan type dengan jelas:
<button type="button">Buka panel</button>
<button type="submit">Simpan</button>Tanpa type, tombol di dalam form bisa default ke submit dan menghasilkan perilaku yang terasa “salah klik”, padahal masalahnya bukan hydration melainkan semantik HTML.
Buat state awal deterministik
Prinsip dasarnya: render awal server dan render awal client harus mengambil keputusan yang sama untuk elemen interaktif. Jika data penting hanya tersedia di browser, ada beberapa pilihan:
- mulai dari state netral atau disabled,
- tampilkan placeholder yang jujur seperti “Memuat...”,
- render komponen interaktif hanya setelah data siap, bila memang perlu.
Trade-off-nya jelas: Anda mungkin mengorbankan sedikit kelincahan visual demi konsistensi perilaku. Dalam banyak kasus, itu keputusan yang tepat.
Jangan gantungkan conditional rendering awal pada API browser
Jika perbedaan mobile vs desktop hanya memengaruhi presentasi, lebih baik serahkan ke CSS daripada mengganti struktur atau handler saat render awal. Jika perilakunya benar-benar berbeda, pertimbangkan fallback yang konsisten lalu tingkatkan setelah mount.
Gunakan key yang stabil dan dapat diprediksi
Pakai identifier domain seperti product.id, user.id, atau slug unik. Hindari:
Math.random(),- timestamp yang berubah tiap render,
- index untuk list yang bisa berubah urutan atau isinya.
Selaraskan visual state dengan interaction state
Jika tombol terlihat loading, pastikan kliknya juga benar-benar ditahan. Jika tombol disabled, jangan hanya mengubah warna; pastikan atribut disabled atau pola aksesibilitas yang setara benar-benar diterapkan. UI yang “terlihat mati” tetapi masih bisa ditekan sama menyesatkannya dengan UI yang “terlihat siap” tetapi tidak bekerja.
Hindari stale closure pada callback penting
Jika handler bergantung pada state terbaru, pastikan callback membaca nilai yang benar. Tinjau penggunaan useCallback, dependensi hook, dan pola state update. Pada aksi kritis seperti checkout, hapus item, atau submit form, lebih baik sedikit eksplisit daripada menyimpan callback yang diam-diam memakai nilai lama.
Checklist pencegahan
- Gunakan elemen
<button>semantik untuk aksi, bukan elemen generik. - Tetapkan
typepada semua tombol di dalam form. - Pastikan state awal deterministik antara server dan client.
- Jangan hitung disabled state dari data browser-only tanpa fallback yang aman.
- Hindari conditional rendering awal yang bergantung pada
window, viewport, atau storage. - Gunakan key stabil untuk item list, jangan index jika urutan bisa berubah.
- Jangan buat id acak saat render bila id itu memengaruhi asosiasi atau target interaksi.
- Audit callback agar tidak memakai closure usang.
- Uji dengan throttling untuk mensimulasikan fase sebelum hydration selesai.
- Perlakukan warning hydration sebagai bug, bukan noise.
- Samakan visual dan perilaku: jika tombol belum siap, tampilkan sebagai belum siap.
Kapan perlu menunda interaktivitas?
Tidak semua tombol harus langsung interaktif pada HTML awal. Untuk aksi yang bergantung pada data client yang belum tersedia, sering kali lebih aman menampilkan state sementara yang jujur daripada memaksa tampilan “siap” yang berpotensi bohong.
Ini memang trade-off: ada sedikit tambahan delay sebelum tombol aktif. Namun dibanding risiko pengguna menekan aksi yang salah, submit ganda, atau kehilangan kepercayaan pada antarmuka, pendekatan konservatif biasanya lebih sehat.
Penutup
Tombol yang terlihat benar tetapi kliknya salah setelah hydration hampir selalu menandakan ketidaksinkronan antara representasi visual dan state logika. Pada aplikasi SSR, ini sering berakar pada perbedaan render server-client, event yang baru aktif saat hydration, conditional rendering yang berubah, disabled state yang tidak deterministik, closure usang, atau key yang tidak stabil.
Prinsip perbaikannya sederhana, meski implementasinya perlu disiplin: gunakan semantic button, buat render awal deterministik, pakai identitas node yang stabil, dan jangan menampilkan affordance yang belum bisa ditepati. Jika sebuah elemen adalah tombol, ia memang punya satu pekerjaan: ketika terlihat bisa diklik untuk melakukan X, maka kliknya harus benar-benar melakukan X.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!