Hydration stabil pada aplikasi SSR tidak dicapai dengan “semoga output server sama dengan client”, tetapi dengan memastikan bentuk data, sumber nilai, dan jalur transformasi benar-benar dibatasi. Jika server merender satu nilai lalu client menghitung ulang dengan asumsi berbeda, React akan melihat mismatch saat hydration.

Penyebabnya biasanya bukan React itu sendiri, melainkan boundary yang longgar: Date diparse ulang dengan timezone berbeda, formatter locale menghasilkan string berbeda, Math.random() dipanggil saat render, feature flag berubah di dua sisi, atau payload SSR membawa data yang tidak tegas bentuknya. Solusinya adalah menjadikan tipe sebagai alat pembatas domain dan transformasi data, bukan sekadar anotasi. Itu inti yang sejalan dengan gagasan “static types and shovels”: tipe berguna ketika dipakai untuk menggali batas yang jelas, bukan hanya menamai data.

Mengapa hydration mismatch terjadi di SSR

Pada SSR, ada dua fase yang harus konsisten:

  1. Server render: HTML dibuat dari data dan state di server.
  2. Client hydration: React di browser memasang event listener dan merekonsiliasi hasil render awal dengan komponen yang dirender ulang di client.

Mismatch terjadi ketika hasil render awal di browser tidak identik dengan HTML dari server. Perbedaan bisa berupa teks, atribut, urutan node, atau cabang UI yang berbeda.

Contoh sumber perbedaan yang sering muncul:

  • Date dan timezone: server merender UTC, client memformat ke zona lokal pengguna.
  • Locale: Intl dapat menghasilkan format berbeda jika locale atau environment berbeda.
  • Random value: Math.random(), UUID, atau ID sementara dipanggil saat render.
  • State turunan: state lokal dibangun ulang dari props dengan logika berbeda atau timing berbeda.
  • Feature flag: evaluasi flag tidak identik antara server dan browser.
  • Browser-only API: window, localStorage, ukuran viewport, preferensi media query.
  • Serialisasi longgar: objek kaya perilaku seperti Date, Map, Set, BigInt, atau field opsional yang tidak konsisten.

Polanya hampir selalu sama: domain data terlalu longgar, lalu ada transformasi implisit di banyak tempat.

Type boundary: batasi data yang boleh menyeberang dari server ke client

Cara paling praktis untuk mencapai hydration stabil adalah mendefinisikan boundary eksplisit antara server dan client. Boundary ini menjawab pertanyaan berikut:

  • Data apa yang boleh dikirim ke client?
  • Dalam bentuk apa data itu diserialisasi?
  • Nilai mana yang final di server, dan nilai mana yang boleh dihitung ulang di client?
  • Validasi apa yang harus dijalankan sebelum data dipakai untuk render?

Prinsipnya:

  1. Jangan kirim domain object mentah langsung ke komponen client.
  2. Ubah ke DTO atau view-model SSR yang stabil, serializable, dan siap render.
  3. Validasi di boundary agar payload benar-benar sesuai kontrak.
  4. Render dari nilai final, bukan dari kalkulasi yang bisa berbeda antar environment.

Tipe statis seperti TypeScript membantu, tetapi tidak cukup sendiri. Saat data datang dari API, database, cookie, environment variable, atau feature flag provider, Anda tetap butuh validasi runtime. TypeScript menjaga kontrak di saat kompilasi; validasi runtime menjaga kontrak saat data benar-benar masuk.

Pola praktis: DTO dan view-model untuk payload SSR

Masalah: domain model terlalu kaya

Misalnya Anda punya data artikel dari server:

type Article = {
  id: string;
  title: string;
  publishedAt: Date;
  author: {
    name: string;
    locale?: string;
  };
};

Jika komponen client menerima Date mentah lalu memformatnya sendiri saat render, mismatch mudah terjadi. Server dan browser bisa berbeda timezone, locale default, atau cara formatting.

Solusi: ubah ke view-model yang stabil

Buat tipe payload khusus untuk SSR. Bukan salinan mentah dari model domain, melainkan bentuk data yang memang aman untuk hydration.

type ArticleCardViewModel = {
  id: string;
  title: string;
  publishedAtIso: string;
  publishedLabel: string;
  authorName: string;
};

Lalu lakukan transformasi di server:

function toArticleCardViewModel(article: Article, locale: string): ArticleCardViewModel {
  return {
    id: article.id,
    title: article.title,
    publishedAtIso: article.publishedAt.toISOString(),
    publishedLabel: new Intl.DateTimeFormat(locale, {
      dateStyle: 'medium',
      timeZone: 'UTC'
    }).format(article.publishedAt),
    authorName: article.author.name
  };
}

Ada dua keputusan penting di sini:

  • publishedAtIso disimpan sebagai nilai mesin yang tegas.
  • publishedLabel dihitung final di server untuk dipakai langsung saat render.

Dengan begitu, komponen client tidak perlu mengulang formatting saat hydration.

Komponen React yang aman untuk hydration

type Props = {
  article: ArticleCardViewModel;
};

export function ArticleCard({ article }: Props) {
  return (
    <article>
      <h2>{article.title}</h2>
      <p>
        <time dateTime={article.publishedAtIso}>{article.publishedLabel}</time>
      </p>
      <p>Oleh {article.authorName}</p>
    </article>
  );
}

Komponen ini hanya merender data final. Tidak ada parsing ulang, tidak ada format locale ulang, dan tidak ada nilai nondeterministik saat render.

Implementasi di React/Next.js: jangan hitung ulang nilai sensitif saat render

Contoh buruk: formatting tanggal di client saat hydration

export function BadDate({ iso }: { iso: string }) {
  return (
    <time>
      {new Date(iso).toLocaleString()}
    </time>
  );
}

Masalahnya:

  • toLocaleString() bergantung pada locale environment.
  • Timezone browser pengguna bisa berbeda dari server.
  • Output string dapat berbeda walau input ISO sama.

Pola lebih aman

export function StableDate({
  iso,
  label
}: {
  iso: string;
  label: string;
}) {
  return <time dateTime={iso}>{label}</time>;
}

Jika Anda memang ingin menyesuaikan tampilan setelah hydration, lakukan sebagai enhancement bertahap, bukan sebagai nilai render awal:

'use client';

import { useEffect, useState } from 'react';

export function LocalizedDate({ iso, initialLabel }: { iso: string; initialLabel: string }) {
  const [label, setLabel] = useState(initialLabel);

  useEffect(() => {
    const next = new Intl.DateTimeFormat(undefined, {
      dateStyle: 'medium',
      timeStyle: 'short'
    }).format(new Date(iso));

    setLabel(next);
  }, [iso]);

  return <time dateTime={iso}>{label}</time>;
}

Teknik ini menjaga hydration awal tetap stabil karena teks pertama sama dengan server. Perubahan locale terjadi setelah hydration selesai.

Jika UI awal harus identik, jangan membuat nilai awal bergantung pada browser-only state.

Sumber mismatch umum dan tindakan teknis untuk mencegahnya

1. Date, timezone, dan locale

Masalah: server dan client merender string tanggal berbeda.

Tindakan:

  • Kirim tanggal sebagai ISO string, bukan Date object.
  • Jika label awal harus stabil, format di server dengan locale dan timezone yang eksplisit.
  • Jangan panggil toLocaleString() langsung di render client untuk konten SSR awal.

2. Random value dan ID sementara

Masalah: Math.random(), UUID, atau nilai acak lain dipakai saat render.

Tindakan:

  • Generate ID di server lalu kirim sebagai bagian dari payload.
  • Jika butuh ID stabil di React, gunakan mekanisme yang memang dirancang stabil untuk render, bukan generator acak saat render.
  • Hindari menghasilkan key list dari nilai random.

3. State turunan dari props

Masalah: komponen membangun state awal dari props dengan logika yang tidak deterministik atau memanggil API browser.

Tindakan:

  • Hitung state turunan penting di server dan kirim sebagai view-model final.
  • Jika state harus berubah di browser, gunakan nilai SSR sebagai initial state lalu update di useEffect.
  • Jangan menyimpan hasil kalkulasi render ke state kecuali memang perlu.

4. Feature flag

Masalah: server merender cabang A, client mengevaluasi ulang dan memilih cabang B.

Tindakan:

  • Evaluasi feature flag di server untuk request tersebut.
  • Kirim hasil evaluasi final sebagai payload SSR, bukan memanggil provider flag lagi saat render awal client.
  • Simpan snapshot flag yang relevan untuk halaman, misalnya { newCheckout: true }.
type FeatureFlags = {
  newCheckout: boolean;
  showPromoBanner: boolean;
};

type PageViewModel = {
  flags: FeatureFlags;
};

5. Browser-only API

Masalah: render awal membaca window, localStorage, ukuran layar, atau preferensi media.

Tindakan:

  • Jangan akses browser-only API di render SSR.
  • Gunakan fallback SSR yang stabil.
  • Pindahkan pembacaan ke useEffect atau komponen client-only jika memang harus.
'use client';

import { useEffect, useState } from 'react';

export function ThemeLabel() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = window.localStorage.getItem('theme');
    if (saved === 'dark' || saved === 'light') {
      setTheme(saved);
    }
  }, []);

  return <span>Tema: {theme}</span>;
}

Di sini nilai awal SSR stabil. Browser boleh menyesuaikan setelah mount.

6. Serialisasi yang longgar

Masalah: payload mengandung tipe yang tidak aman atau tidak konsisten ketika diserialisasi/deserialisasi.

Tindakan:

  • Batasi payload SSR ke tipe JSON-friendly: string, number, boolean, null, array, object sederhana.
  • Jangan mengandalkan instance class atau objek dengan metode.
  • Konversi Date, BigInt, enum internal, dan struktur kompleks ke bentuk eksplisit.
  • Tegaskan field opsional: bedakan null, undefined, dan field yang memang wajib.

Validasi runtime di boundary: TypeScript saja tidak cukup

TypeScript tidak memverifikasi payload runtime. Jika API mengembalikan data yang melenceng, komponen Anda tetap bisa merender nilai yang salah dan memicu mismatch. Karena itu, boundary SSR perlu validasi runtime.

Anda bisa memakai library validasi skema apa pun yang cocok dengan stack tim. Yang penting adalah polanya: validasi data eksternal, lalu transformasikan ke view-model yang aman.

type ArticleApiPayload = {
  id: unknown;
  title: unknown;
  publishedAt: unknown;
  author: {
    name: unknown;
  } | null;
};

function assertArticlePayload(input: ArticleApiPayload) {
  if (typeof input.id !== 'string') throw new Error('Invalid article.id');
  if (typeof input.title !== 'string') throw new Error('Invalid article.title');
  if (typeof input.publishedAt !== 'string') throw new Error('Invalid article.publishedAt');
  if (!input.author || typeof input.author.name !== 'string') {
    throw new Error('Invalid article.author.name');
  }
}

function toArticleViewModel(input: ArticleApiPayload, locale: string) {
  assertArticlePayload(input);

  const date = new Date(input.publishedAt);
  if (Number.isNaN(date.getTime())) {
    throw new Error('Invalid article.publishedAt date');
  }

  return {
    id: input.id,
    title: input.title,
    publishedAtIso: date.toISOString(),
    publishedLabel: new Intl.DateTimeFormat(locale, {
      dateStyle: 'medium',
      timeZone: 'UTC'
    }).format(date),
    authorName: input.author.name
  };
}

Keuntungan pendekatan ini:

  • Error muncul di boundary, bukan tersebar di komponen.
  • Komponen menerima data yang sudah bersih dan stabil.
  • Jalur transformasi lebih mudah diuji.

Pola arsitektur sederhana yang bisa dipakai tim frontend

1. Pisahkan tipe per lapisan

  • API payload: bentuk data mentah dari API/backend.
  • Domain model: representasi internal jika memang diperlukan.
  • SSR view-model / DTO: bentuk final yang aman untuk dirender dan dihydrate.

Jangan gunakan satu tipe untuk semua lapisan. Satu tipe serbaguna biasanya membuat boundary longgar.

2. Buat fungsi transformasi satu arah

Alih-alih membiarkan komponen melakukan parsing sendiri, sediakan fungsi eksplisit seperti:

  • parseXxxPayload()
  • toPageViewModel()
  • serializeXxxForClient()

Keuntungannya adalah semua keputusan sensitif berada di satu tempat dan mudah diuji.

3. Anggap hydration sebagai kontrak output

Tanyakan di code review:

  • Apakah komponen ini merender nilai yang bisa berbeda di browser?
  • Apakah ada transformasi tersembunyi di JSX?
  • Apakah ada object kaya perilaku yang menyeberang boundary?
  • Apakah feature flag sudah disnapshot?

4. Simpan nilai presentasi yang memang penting

Tidak semua formatting harus dihitung di client. Untuk konten SSR kritis seperti harga, tanggal publikasi, badge status, atau label A/B, sering lebih aman mengirim:

  • nilai mentah untuk mesin, dan
  • label final untuk tampilan awal.

Trade-off-nya adalah payload sedikit lebih besar, tetapi stabilitas hydration dan prediktabilitas UI biasanya jauh lebih berharga.

Contoh struktur Next.js yang rapi

Contoh alur sederhana:

  1. Ambil data di server.
  2. Validasi payload eksternal.
  3. Transformasi ke view-model SSR.
  4. Render komponen dari view-model itu.
// app/articles/[id]/page.tsx
import { ArticleCard } from '@/components/article-card';

async function getArticleFromApi(id: string) {
  const res = await fetch(`https://example.com/api/articles/${id}`, {
    cache: 'no-store'
  });

  if (!res.ok) throw new Error('Failed to fetch article');
  return res.json();
}

export default async function ArticlePage({ params }: { params: { id: string } }) {
  const raw = await getArticleFromApi(params.id);
  const article = toArticleViewModel(raw, 'id-ID');

  return <ArticleCard article={article} />;
}

Hal pentingnya bukan pada API spesifik, melainkan urutannya: fetch → validate → transform → render. Bukan fetch → oper ke client → semoga aman.

Trade-off dan batasan pendekatan ini

Lebih banyak tipe dan fungsi transformasi

Ya, ada tambahan kode. Tetapi biaya ini biasanya sebanding dengan berkurangnya bug hydration, formatting tak konsisten, dan debugging yang mahal.

Payload bisa sedikit lebih besar

Jika Anda mengirim nilai mentah plus label final, ukuran payload naik. Namun untuk banyak halaman SSR, tambahan kecil ini sering lebih murah dibanding UI yang tidak stabil atau warning hydration yang sulit dilacak.

Perlu disiplin tim

Tanpa aturan review dan kebiasaan yang konsisten, komponen bisa kembali melakukan parsing atau formatting sendiri. Solusi teknis perlu didukung konvensi tim.

Checklist debugging hydration mismatch langkah demi langkah

Jika warning hydration sudah muncul, gunakan checklist ini:

  1. Temukan node yang berbeda
    Periksa warning di console dan bandingkan teks atau atribut yang mismatch.
  2. Cari nilai nondeterministik di render
    Telusuri penggunaan Date, Math.random(), UUID, locale formatting, dan feature flag.
  3. Periksa browser-only API
    Cari akses ke window, document, localStorage, viewport, atau media query pada render awal.
  4. Audit boundary payload SSR
    Apakah komponen menerima Date, class instance, field opsional ambigu, atau data mentah dari API?
  5. Bandingkan hasil render server dan data client awal
    Log payload view-model di server dan log props awal di client. Pastikan nilainya identik.
  6. Bekukan formatting awal
    Untuk tanggal, angka, mata uang, dan label status, kirim string final dari server untuk render awal.
  7. Pindahkan perbedaan environment ke effect
    Jika nilai memang harus bergantung pada browser, render fallback yang stabil dulu lalu update setelah mount.
  8. Tambahkan validasi runtime
    Jika mismatch berasal dari data tak terduga, validasi di boundary sebelum render.
  9. Periksa feature flag snapshot
    Pastikan client tidak mengevaluasi ulang flag dengan hasil berbeda pada render pertama.
  10. Tulis test untuk jalur transformasi
    Uji fungsi toViewModel() agar outputnya stabil untuk input yang sama.

Kesalahan umum yang sering lolos code review

  • Memanggil new Date() langsung di JSX untuk label waktu “sekarang”.
  • Menggunakan toLocaleString() tanpa locale/timezone eksplisit.
  • Membaca tema dari localStorage untuk menentukan markup awal.
  • Meneruskan respons API mentah ke komponen presentasi.
  • Mencampur logika fetch, parse, dan render di komponen yang sama.
  • Menganggap TypeScript sudah cukup menjamin bentuk data runtime.
  • Mengirim object yang secara teknis bisa lolos serialisasi, tetapi semantiknya ambigu di client.

Penutup

Hydration stabil di SSR bukan hasil dari satu trik, melainkan dari boundary yang tegas. Data yang menyeberang dari server ke client harus punya bentuk yang sederhana, tervalidasi, dan siap render. Tipe menjadi berguna ketika dipakai untuk membatasi domain dan jalur transformasi data, bukan sekadar memberi nama pada struktur yang longgar.

Jika tim frontend Anda sering bertemu mismatch, mulai dari langkah yang paling berdampak: buat DTO/view-model SSR, validasi data di boundary, kirim nilai presentasi yang final untuk render awal, dan pindahkan logika yang bergantung pada browser ke fase setelah hydration. Dengan pola ini, warning hydration berkurang bukan karena disembunyikan, tetapi karena akar penyebabnya dihilangkan.