Hydration mismatch pada aplikasi SSR biasanya terjadi ketika HTML hasil render server tidak sama dengan hasil render awal di browser. Masalah ini sering lebih sulit didiagnosis pada komponen visual dinamis seperti canvas, SVG generatif, chart, atau ilustrasi yang bergantung pada waktu, ukuran layar, random, devicePixelRatio, font, atau state yang berubah saat mount.

Jika Anda pernah membuat output visual generatif—mirip semangat eksperimen menggambar ala 9front drawing—masalah utamanya sama: hasil gambar sering tidak deterministik. Pada web modern, ketidakdeterministikan itu bertabrakan dengan aturan SSR: render pertama di server dan render pertama di client harus identik, atau proses hydration akan memunculkan warning, UI berkedip, node diganti ulang, atau perilaku event menjadi tidak stabil.

Apa itu hydration mismatch dan kenapa canvas/SVG sering memicunya?

Pada SSR, server mengirim HTML awal. Setelah itu, React di browser melakukan hydrate: ia mengaitkan event handler dan state ke markup yang sudah ada. Agar proses ini aman, React mengasumsikan struktur dan isi render pertama di browser sama dengan yang dikirim server.

Komponen visual dinamis sering melanggar asumsi ini karena:

  • Menggunakan nilai yang hanya tersedia di browser, seperti window.innerWidth, matchMedia, devicePixelRatio, atau ukuran elemen aktual.
  • Menggunakan sumber ketidakpastian, seperti Math.random() atau waktu sekarang.
  • Bergantung pada font atau layout nyata yang belum sama antara server dan browser.
  • Mengubah state awal saat mount, sehingga render pertama client berbeda dari hasil SSR.
  • Menggambar secara imperatif terlalu cepat, misalnya menghitung ukuran canvas saat render padahal ukuran final baru diketahui setelah layout selesai.

Canvas sendiri tidak selalu menghasilkan mismatch hanya karena melakukan drawing. Masalahnya muncul ketika markup awal, atribut, teks fallback, ukuran, atau struktur komponen berbeda antara server dan client. Pada SVG, risikonya lebih langsung karena bentuk dan atribut SVG adalah bagian dari tree DOM yang di-hydrate.

Gejala yang biasa terlihat

Gejala hydration mismatch pada render canvas dinamis tidak selalu berupa error fatal. Yang sering muncul justru kombinasi tanda kecil berikut:

  • Warning React seperti Text content does not match server-rendered HTML atau pesan hydration failed.
  • Canvas/SVG tampak berkedip: hasil SSR muncul sebentar lalu diganti.
  • Ukuran gambar berubah setelah mount.
  • Warna, posisi, atau jumlah elemen visual berbeda antar refresh.
  • Event handler terasa aneh karena subtree tertentu di-recreate oleh React.
  • Masalah hanya muncul di production build, bukan saat development lokal.

Catatan: Development mode kadang menyamarkan atau justru memperjelas gejala tertentu karena render tambahan, strict mode, atau tooling overlay. Selalu verifikasi pada build production saat memburu hydration mismatch.

Penyebab paling umum pada render visual dinamis

1. Menggunakan waktu saat render

Contoh klasik adalah memanggil Date.now() atau new Date() di dalam fungsi komponen untuk menentukan seed, warna, posisi, atau label. Server dan client hampir pasti menghasilkan nilai berbeda.

function BadCanvas() {
  const seed = Date.now();
  return <div data-seed={seed}>Canvas seed: {seed}</div>;
}

Jika nilai itu memengaruhi atribut DOM atau teks, mismatch akan terjadi. Jika hanya dipakai dalam useEffect untuk menggambar setelah mount, Anda menghindari mismatch markup, tetapi tetap berisiko mendapat hasil visual yang berubah-ubah.

2. Menggunakan random tanpa seed stabil

Math.random() di render SSR dan render client hampir pasti berbeda. Ini sangat umum pada generative art, partikel, pola noise, atau variasi dekoratif.

function BadSvg() {
  const x = Math.floor(Math.random() * 100);
  return (
    <svg width="100" height="20">
      <circle cx={x} cy="10" r="4" fill="tomato" />
    </svg>
  );
}

Pada SVG, ini langsung mengubah DOM. Pada canvas, mismatch biasanya muncul jika random juga memengaruhi atribut seperti width, height, fallback text, atau state render awal.

3. Bergantung pada ukuran layar atau ukuran elemen saat render

Server tidak tahu ukuran viewport pengguna. Jika Anda merender berdasarkan window.innerWidth atau hasil getBoundingClientRect() saat render pertama client, hasilnya akan berbeda dari SSR.

function BadResponsiveGraphic() {
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
  return isMobile ? <SmallArt /> : <LargeArt />;
}

Pola ini berbahaya karena server akan memilih satu cabang, client bisa memilih cabang lain, lalu hydration gagal.

4. devicePixelRatio dan ukuran canvas

Canvas sering diatur seperti ini: ukuran CSS tetap, tetapi ukuran bitmap internal dikalikan devicePixelRatio agar tajam. Nilai ini hanya akurat di browser dan bisa berbeda antar perangkat. Jika Anda menggunakannya untuk menentukan atribut width dan height saat render awal, server dan client bisa tidak sinkron.

5. Perbedaan font dan text metrics

Render visual yang menghitung posisi berdasarkan ukuran teks sering bermasalah. Server tidak memiliki kondisi font yang sama dengan browser pengguna. Bahkan jika markup sama, hasil gambar di canvas setelah mount bisa berubah ketika font web selesai dimuat. Ini kadang tidak memicu hydration mismatch langsung, tetapi memicu pergeseran visual yang tampak seperti bug hydration.

6. State awal berubah saat mount

Bug yang sering terjadi di React/Next.js adalah state awal diambil dari satu nilai saat SSR, lalu segera diperbarui di client pada useEffect atau inisialisasi kondisional.

function BadComponent() {
  const [dpr, setDpr] = React.useState(1);

  if (typeof window !== 'undefined') {
    // anti-pattern: update berdasarkan browser pada jalur render
  }

  React.useEffect(() => {
    setDpr(window.devicePixelRatio || 1);
  }, []);

  return <canvas width={300 * dpr} height={150 * dpr} />;
}

Jika SSR menghasilkan width=300 tetapi client render pertama sudah menghasilkan nilai lain, React akan memperingatkan mismatch. Bahkan jika update baru terjadi setelah effect, visual pertama bisa tetap meloncat.

Contoh bug minimal di React/Next.js

Berikut contoh yang terlihat sederhana tetapi sering menjadi akar masalah.

import React from 'react';

export default function HeroPattern() {
  const dots = Array.from({ length: 5 }, () => ({
    x: Math.floor(Math.random() * 200),
    y: Math.floor(Math.random() * 80),
  }));

  return (
    <svg width="200" height="80" viewBox="0 0 200 80">
      {dots.map((dot, i) => (
        <circle key={i} cx={dot.x} cy={dot.y} r="3" fill="currentColor" />
      ))}
    </svg>
  );
}

Di server, lima titik dibuat dengan random A. Di browser, render awal membuat random B. Karena atribut cx dan cy berbeda, hydration mismatch sangat mungkin terjadi.

Versi yang lebih aman adalah memastikan seed stabil berasal dari props atau data server, lalu gunakan generator pseudo-random deterministik.

import React from 'react';

function createRng(seed) {
  let value = seed % 2147483647;
  if (value <= 0) value += 2147483646;
  return function next() {
    value = (value * 16807) % 2147483647;
    return (value - 1) / 2147483646;
  };
}

export default function HeroPattern({ seed }) {
  const rng = createRng(seed);
  const dots = Array.from({ length: 5 }, () => ({
    x: Math.floor(rng() * 200),
    y: Math.floor(rng() * 80),
  }));

  return (
    <svg width="200" height="80" viewBox="0 0 200 80">
      {dots.map((dot, i) => (
        <circle key={i} cx={dot.x} cy={dot.y} r="3" fill="currentColor" />
      ))}
    </svg>
  );
}

Selama seed sama di server dan client, render pertama akan identik.

Langkah diagnosis yang efektif

1. Tentukan dulu: mismatch DOM atau hanya hasil gambar berubah?

Pada canvas, drawing tidak selalu tercermin di DOM. Jadi bedakan dua kasus:

  • Mismatch hydration nyata: atribut, teks, struktur, atau jumlah node berbeda.
  • Perubahan visual pasca-mount: DOM sama, tetapi gambar canvas berubah setelah effect berjalan.

Keduanya perlu diperbaiki, tetapi teknik diagnosisnya berbeda.

2. Cari sumber nilai non-deterministik di render

Audit fungsi komponen dan semua helper yang dipanggil saat render. Cari penggunaan:

  • Math.random()
  • Date.now(), new Date()
  • window, document, navigator
  • devicePixelRatio, viewport, ukuran elemen
  • pembacaan local storage atau media query

Jika nilai itu memengaruhi JSX atau atribut, itu kandidat utama.

3. Bekukan output render pertama

Tambahkan logging sementara untuk membandingkan nilai yang dipakai server dan client. Untuk komponen yang bermasalah, log seed, ukuran, mode, jumlah shape, dan atribut penting.

const debug = {
  seed,
  width,
  height,
  dpr,
  variant,
};

console.log('render debug', debug);

Pada lingkungan SSR, log server dan log browser harus menghasilkan nilai awal yang sama untuk semua hal yang memengaruhi markup.

4. Sederhanakan sampai mismatch hilang

Metode praktisnya adalah menghapus fitur sedikit demi sedikit:

  1. Ganti output SVG/canvas menjadi placeholder statis.
  2. Tambahkan kembali ukuran dinamis.
  3. Tambahkan random berbasis seed.
  4. Tambahkan drawing setelah mount.
  5. Tambahkan font dan text metrics terakhir.

Saat mismatch muncul kembali, Anda sudah menemukan kelas penyebabnya.

5. Uji build production

Jalankan build production dan buka halaman yang bermasalah. Banyak bug hydration baru konsisten terlihat pada hasil build final, terutama jika ada optimasi bundling atau jalur render yang berbeda.

Pola perbaikan yang paling aman

1. Pastikan render pertama identik

Aturan paling penting: jangan biarkan render awal bergantung pada kondisi browser yang tidak diketahui server. Jika butuh ukuran viewport atau DPR aktual, render placeholder yang stabil terlebih dahulu, lalu tingkatkan setelah mount.

import React from 'react';

export default function StableCanvasShell() {
  return (
    <div style={{ width: 300, height: 150 }}>
      <canvas width="300" height="150" />
    </div>
  );
}

Markup awal ini bisa identik di server dan client. Enhancement dinamis dilakukan sesudahnya.

2. Pindahkan akses browser-only ke effect

Nilai seperti ukuran elemen, DPR, atau media query sebaiknya dibaca di useEffect atau useLayoutEffect di sisi client. Namun, pahami trade-off-nya: render awal harus tetap memakai fallback yang stabil.

import React from 'react';

export default function CanvasWithClientMeasure() {
  const ref = React.useRef(null);

  React.useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;

    const dpr = window.devicePixelRatio || 1;
    const cssWidth = 300;
    const cssHeight = 150;

    canvas.width = Math.floor(cssWidth * dpr);
    canvas.height = Math.floor(cssHeight * dpr);
    canvas.style.width = cssWidth + 'px';
    canvas.style.height = cssHeight + 'px';

    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    ctx.scale(dpr, dpr);
    ctx.fillStyle = '#222';
    ctx.fillRect(10, 10, 100, 40);
  }, []);

  return <canvas ref={ref} width="300" height="150" />;
}

Pada contoh ini, atribut awal tetap stabil. Penyesuaian DPR dilakukan setelah mount. Trade-off-nya adalah gambar pertama mungkin sedikit berubah setelah effect, tetapi hydration tetap aman.

3. Gunakan seed deterministik untuk visual generatif

Jika desain memang harus acak, jangan gunakan random murni pada render awal. Gunakan seed yang berasal dari props, ID data, slug halaman, atau hasil perhitungan server. Dengan begitu, server dan client menghasilkan pola yang sama.

Ini sangat relevan untuk pola dekoratif, noise, confetti statis, avatar generatif, atau preview chart.

4. Pisahkan shell SSR dari drawing imperatif

Untuk canvas, pendekatan yang paling tahan bug adalah memisahkan:

  • Shell SSR: elemen container/canvas dengan ukuran default stabil.
  • Drawing client-only: kode imperatif yang menggambar setelah mount.

Dengan cara ini, SSR tetap berguna untuk layout dan mencegah loncatan besar, tetapi logika visual yang bergantung pada browser tidak ikut menentukan markup awal.

5. Hindari percabangan markup berdasarkan window pada render

Jangan memilih subtree berbeda hanya karena viewport atau media query saat render awal. Jika perlu responsif, lebih aman gunakan CSS untuk layout awal, lalu sesuaikan drawing setelah mount.

6. Stabilkan font atau tunda drawing teks

Jika gambar bergantung pada ukuran teks, ada beberapa opsi:

  • Gunakan font sistem yang lebih konsisten untuk render awal.
  • Tunggu font web siap sebelum menggambar teks final.
  • Render placeholder tanpa text metrics, lalu redraw setelah font tersedia.

Trade-off-nya adalah Anda harus memilih antara konsistensi visual awal dan akurasi hasil akhir.

Kapan perlu memakai dynamic import dengan no-SSR?

Pada Next.js, ada kasus di mana komponen memang lebih masuk akal dirender client-only. Misalnya:

  • Komponen sepenuhnya bergantung pada API browser.
  • Output visual tidak punya nilai SEO.
  • Markup awal tidak penting untuk aksesibilitas atau konten utama.
  • Biaya menjaga determinisme lebih tinggi daripada manfaat SSR.

Pola ini berguna untuk editor grafis, visualisasi interaktif berat, atau animasi kanvas yang seluruh logikanya baru masuk akal setelah browser tersedia.

import dynamic from 'next/dynamic';

const ClientOnlyCanvas = dynamic(() => import('./ClientOnlyCanvas'), {
  ssr: false,
});

export default function Page() {
  return <ClientOnlyCanvas />;
}

Kapan memilih ini: saat hydration mismatch sulit dihindari karena komponen memang tidak punya representasi SSR yang stabil.

Trade-off:

  • Tidak ada HTML SSR untuk komponen tersebut.
  • Konten baru muncul setelah JavaScript client siap.
  • Bisa berdampak pada perceived performance dan SEO jika komponen memuat konten penting.

Jadi, no-SSR bukan solusi default. Gunakan ketika komponen memang bersifat browser-native dan tidak kritis untuk output awal.

Pola implementasi yang direkomendasikan

Pola A: SSR shell stabil + redraw setelah mount

Cocok untuk canvas responsif atau visual dekoratif ringan.

  • Server mengirim ukuran default tetap.
  • Client mengukur container pada effect.
  • Canvas di-resize dan digambar ulang di browser.

Keuntungannya: aman untuk hydration dan layout awal stabil. Kekurangannya: ada fase enhancement setelah mount.

Pola B: SVG deterministik berbasis props

Cocok jika Anda butuh SSR penuh dan output visual harus langsung terlihat.

  • Semua parameter visual berasal dari props atau data server.
  • Random diganti seed deterministik.
  • Tidak membaca viewport saat render.

Keuntungannya: kompatibel dengan SSR dan hydration. Kekurangannya: responsivitas dinamis perlu strategi tambahan.

Pola C: Client-only visual

Cocok untuk visualisasi berat, tool interaktif, atau animasi yang tidak punya nilai SSR.

  • Gunakan dynamic import no-SSR.
  • Sediakan placeholder atau skeleton jika perlu.

Keuntungannya: menghilangkan kelas masalah hydration tertentu. Kekurangannya: hilang manfaat SSR untuk komponen itu.

Kesalahan yang sering dilakukan

  • Menggunakan guard typeof window !== 'undefined' langsung di JSX lalu mengubah subtree berdasarkan hasilnya. Ini sering tetap menghasilkan dua render awal yang berbeda.
  • Menganggap canvas aman karena tidak banyak DOM. Padahal atribut ukuran, fallback text, dan wrapper tetap bagian dari hydration.
  • Menyimpan seed di state yang diinisialisasi dari random. Jika state awal dibuat saat render, masalahnya tetap sama.
  • Mengukur layout terlalu dini sebelum elemen benar-benar memiliki ukuran final.
  • Mengabaikan font loading saat posisi elemen visual bergantung pada text metrics.
  • Langsung mematikan SSR untuk semua komponen visual, padahal sebagian bisa diperbaiki dengan shell stabil dan seed deterministik.

Checklist agar render pertama tetap identik

  • Apakah render pertama bebas dari Math.random() dan waktu saat ini?
  • Apakah semua data yang memengaruhi markup berasal dari props atau hasil server yang stabil?
  • Apakah komponen menghindari pembacaan window, document, viewport, dan devicePixelRatio saat render awal?
  • Apakah atribut width, height, class, style, dan teks awal sama antara server dan client?
  • Apakah responsivitas awal bisa ditangani oleh CSS, bukan percabangan JSX berbasis viewport?
  • Apakah drawing imperatif dipindahkan ke effect setelah mount?
  • Apakah visual generatif memakai seed deterministik?
  • Apakah font yang memengaruhi pengukuran teks sudah dipertimbangkan?
  • Jika komponen murni browser-only, apakah lebih tepat dijadikan dynamic import dengan no-SSR?

Penutup

Debug UI SSR mismatch hydration dari render canvas dinamis pada dasarnya adalah latihan menjaga determinisme. Output visual generatif memang menggoda untuk dibuat berdasarkan random, waktu, ukuran layar, atau kondisi perangkat, tetapi SSR menuntut render awal yang sama persis di server dan client.

Strategi yang paling andal biasanya salah satu dari tiga: buat output awal deterministik, pisahkan shell SSR dari drawing client-only, atau nonaktifkan SSR untuk komponen yang memang sepenuhnya bergantung pada browser. Jika Anda disiplin memeriksa sumber nilai non-deterministik dan menjaga render pertama tetap identik, sebagian besar hydration mismatch pada canvas/SVG bisa dilokalisasi dan diperbaiki tanpa menebak-nebak.