Hydration drift pada SSR UI terjadi ketika HTML hasil render di server tidak cocok dengan hasil render awal di client saat proses hidrasi. Dalam praktiknya, masalah ini sering muncul bukan karena mesin hydration itu sendiri, melainkan karena type narrowing, generic constraint, atau asumsi bentuk data yang tampak aman di TypeScript tetapi tidak benar-benar dijaga saat runtime.
Pola ini mirip dengan gagasan Only Bounds: batas tipe atau constraint generik hanya menyatakan apa yang minimal boleh diasumsikan, bukan menjamin bentuk konkret yang sama di semua titik eksekusi. Di UI SSR seperti React/Next.js, perbedaan kecil pada bentuk data antara server dan client cukup untuk membuat cabang render berbeda, lalu memicu warning hydration mismatch, UI berkedip, atau elemen yang diganti ulang oleh client.
Apa yang dimaksud hydration drift dari type narrowing?
Istilah hydration drift di sini merujuk pada penyimpangan hasil render antara server dan client yang berawal dari asumsi data. Secara umum alurnya seperti ini:
- Server merender komponen berdasarkan hasil narrowing atau pengecekan bentuk data tertentu.
- Data yang sampai ke client tidak identik secara struktural, atau dipulihkan dengan cara berbeda.
- Client menjalankan render awal dengan hasil cabang logika yang berbeda.
- React mendeteksi perbedaan antara DOM server dan virtual tree di client.
Yang membuat bug ini sulit dideteksi adalah TypeScript sering terlihat "baik-baik saja". Compiler menerima kode karena narrowing valid secara statis, tetapi data runtime yang datang dari JSON, API, cache, atau local state tidak selalu memenuhi asumsi yang sama.
Gejala umum di UI SSR
- Warning seperti Text content does not match server-rendered HTML.
- Komponen tertentu berkedip saat halaman dimuat.
- Elemen yang tadinya muncul di server hilang setelah hidrasi, atau sebaliknya.
- Class, atribut, atau struktur child berbeda setelah client mengambil alih.
- Bug tampak acak karena hanya terjadi pada variasi data tertentu.
Gejala ini sering terlihat seperti masalah hydration murni. Padahal akar masalahnya bisa berupa kontrak data yang tidak konsisten, misalnya server menerima objek lengkap, sementara client menerima versi yang sudah diserialisasi sehingga beberapa properti hilang, berubah tipe, atau bernilai null/undefined secara berbeda.
Mengapa type narrowing bisa menipu pada SSR?
1. Narrowing hanya berlaku untuk nilai saat ini, bukan untuk perjalanan data lintas proses
TypeScript mempersempit tipe berdasarkan kondisi seperti in, typeof, atau pengecekan properti. Ini aman untuk blok kode itu, tetapi tidak otomatis berarti hasil serialisasi, cache, atau rehydration di client akan memiliki bentuk yang sama.
2. Generic constraint bukan jaminan bentuk konkret
Jika sebuah komponen ditulis dengan batas seperti T extends { kind?: string }, kita hanya tahu bahwa T memiliki minimal karakteristik itu. Kita tidak boleh diam-diam memperlakukan T sebagai salah satu variasi konkret tertentu tanpa guard runtime yang tegas.
3. JSON menghapus informasi penting
Objek seperti Date, Map, instance class, getter, prototype custom, atau properti non-enumerable tidak dipulihkan identik setelah serialisasi biasa. Akibatnya, cabang render yang bergantung pada instanceof, method instance, atau properti turunan bisa berbeda antara server dan client.
4. Optional field sering disalahartikan
Pengecekan seperti if (data.image) atau if ('items' in data) terlihat sederhana, tetapi hasilnya bisa berubah jika payload client menormalkan field menjadi array kosong, null, atau menghapus properti sama sekali.
Contoh nyata: komponen bercabang berdasarkan bentuk data
Misalkan Anda memiliki kartu produk yang bisa menerima dua bentuk data: hasil pencarian ringkas atau detail produk penuh. Di server, data mungkin berasal dari query internal yang lengkap. Di client, data yang sama bisa berasal dari payload JSON yang sudah dinormalisasi berbeda.
Contoh before: narrowing yang tampak aman tetapi rapuh
type SearchProduct = {
id: string;
name: string;
price: number;
};
type DetailedProduct = {
id: string;
name: string;
price: number;
discount: {
percentage: number;
};
};
type Product = SearchProduct | DetailedProduct;
function ProductCard({ product }: { product: Product }) {
const hasDiscount = 'discount' in product;
return (
<article>
<h2>{product.name}</h2>
<p>Rp {product.price}</p>
{hasDiscount ? (
<span className="badge">
Diskon {product.discount.percentage}%
</span>
) : null}
</article>
);
}Masalahnya, pengecekan 'discount' in product hanya memeriksa keberadaan properti, bukan validitas bentuk nilainya. Server mungkin merender dengan objek:
{
"id": "p1",
"name": "Keyboard",
"price": 500000,
"discount": { "percentage": 10 }
}Tetapi client bisa menerima payload hasil transformasi seperti:
{
"id": "p1",
"name": "Keyboard",
"price": 500000,
"discount": null
}Jika kode lain, cache layer, atau API adapter mengganti objek diskon menjadi null, maka asumsi bentuk detail pecah. Bahkan jika tidak melempar error, cabang render bisa berubah. Dalam kasus lain, properti discount mungkin hilang sama sekali di client sehingga server menampilkan badge diskon, tetapi client tidak.
Dampaknya pada hydration
Jika server menghasilkan HTML dengan elemen <span class="badge"> tetapi render awal di client menghasilkan null, React akan melihat struktur child yang berbeda. Ini inti dari hydration drift: data yang tampak satu tipe secara statis ternyata tidak stabil secara runtime.
Kasus yang lebih halus: generic constraint dan “Only Bounds”
Pertimbangkan komponen generik yang menerima entitas apa pun selama memiliki field minimal tertentu.
type WithPreview = {
title: string;
preview?: string;
};
function PreviewPanel<T extends WithPreview>({ item }: { item: T }) {
return (
<section>
<h3>{item.title}</h3>
{item.preview ? <p>{item.preview}</p> : <em>Tidak ada preview</em>}
</section>
);
}Secara tipe, ini valid. Namun constraint T extends WithPreview hanya memberi tahu bahwa preview bisa ada atau tidak. Jika server membentuk preview dari proses formatting sinkron, sementara client mengisi ulang state dari payload tanpa field itu, hasil render awal akan berbeda.
Di sinilah ide Only Bounds berguna: jangan menarik kesimpulan lebih jauh dari batas tipe yang tersedia. Jika properti opsional menentukan struktur UI, jadikan bentuk data final eksplisit dan deterministik sebelum render, bukan berharap generic constraint akan “cukup aman”.
Kapan ini terlihat seperti bug hydration, padahal akar masalahnya kontrak data?
Beberapa tanda berikut biasanya mengarah ke kontrak data, bukan bug framework:
- Mismatch hanya terjadi pada record tertentu, bukan seluruh halaman.
- Setelah menambahkan logging payload server dan client, bentuk datanya berbeda tipis.
- Masalah hilang ketika conditional rendering disederhanakan.
- Masalah muncul setelah menambah serializer, cache, atau layer transformasi API.
- TypeScript tidak mengeluh, tetapi runtime payload punya variasi yang tidak tercermin dalam union type.
Jika HTML server dan render client berbeda pada komponen yang bercabang berdasarkan bentuk data, audit kontrak data lebih dulu sebelum menyalahkan hydration engine.
Strategi pencegahan yang benar-benar membantu
1. Buat data deterministik sebelum masuk ke komponen
Komponen SSR idealnya menerima props yang sudah final, bukan union mentah yang masih perlu ditebak bentuknya. Normalisasi di boundary server atau data loader membuat server dan client berbagi kontrak yang sama.
type ProductViewModel = {
id: string;
name: string;
priceLabel: string;
hasDiscount: boolean;
discountPercentage: number | null;
};
function toProductViewModel(input: unknown): ProductViewModel {
const obj = input as Record<string, unknown>;
const discount =
obj.discount &&
typeof obj.discount === 'object' &&
typeof (obj.discount as Record<string, unknown>).percentage === 'number'
? (obj.discount as { percentage: number })
: null;
return {
id: String(obj.id ?? ''),
name: String(obj.name ?? ''),
priceLabel: `Rp ${Number(obj.price ?? 0)}`,
hasDiscount: discount !== null,
discountPercentage: discount?.percentage ?? null,
};
}Dengan pendekatan ini, komponen tidak lagi bergantung pada narrowing rapuh saat render.
2. Normalisasi props, bukan hanya mengetik props
Kesalahan umum adalah menganggap definisi TypeScript pada props sudah cukup. Pada SSR, yang lebih penting adalah normalisasi runtime. Jika komponen perlu boolean, string final, atau array final, kirimkan itu secara eksplisit.
Contoh after: render menjadi stabil
type ProductCardProps = {
product: {
name: string;
priceLabel: string;
hasDiscount: boolean;
discountPercentage: number | null;
};
};
function ProductCard({ product }: ProductCardProps) {
return (
<article>
<h2>{product.name}</h2>
<p>{product.priceLabel}</p>
{product.hasDiscount && product.discountPercentage !== null ? (
<span className="badge">
Diskon {product.discountPercentage}%
</span>
) : null}
</article>
);
}Perbedaannya penting: komponen tidak lagi menebak apakah discount ada dan valid. Semua keputusan telah dipadatkan ke kontrak yang deterministik.
3. Gunakan guard runtime untuk payload yang tidak dipercaya
Jika data berasal dari API, query string, localStorage, cache, atau hydration state, validasi bentuknya di runtime. Tidak harus selalu memakai library; guard manual sederhana sering cukup selama fokus pada field yang benar-benar memengaruhi cabang render.
function hasValidDiscount(
value: unknown
): value is { discount: { percentage: number } } {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
const discount = record.discount;
if (!discount || typeof discount !== 'object') return false;
return typeof (discount as Record<string, unknown>).percentage === 'number';
}Guard seperti ini lebih jujur daripada mengandalkan 'discount' in product semata.
4. Lakukan serialisasi eksplisit
Jangan serahkan objek kompleks ke pipeline SSR lalu berharap bentuknya tetap sama setelah sampai ke client. Ubah menjadi data serial yang stabil:
Datemenjadi string ISO.MapdanSetmenjadi array atau object biasa.- Instance class menjadi plain object.
- Nilai turunan seperti label harga dihitung lebih awal jika memang menentukan UI awal.
Serialisasi eksplisit mengurangi peluang server dan client melakukan interpretasi berbeda terhadap nilai yang sama.
5. Audit conditional rendering yang bergantung pada bentuk data
Periksa semua cabang seperti berikut:
if ('x' in obj)if (obj.optionalField)if (Array.isArray(value))if (value instanceof Date)if (obj.kind === 'foo')tanpa fallback yang aman
Pertanyaannya bukan hanya “apakah ini valid menurut TypeScript?”, tetapi “apakah kondisi ini akan menghasilkan keputusan yang sama di server dan client untuk payload yang benar-benar lewat?”
Contoh bug yang sering luput: discriminated union yang tidak benar-benar diskriminatif
Union type idealnya memakai discriminator yang stabil, misalnya type: 'summary' | 'full'. Bug muncul ketika kode malah menebak varian berdasarkan keberadaan field sampingan.
type SummaryUser = {
type: 'summary';
id: string;
name: string;
};
type FullUser = {
type: 'full';
id: string;
name: string;
bio: string;
};
type User = SummaryUser | FullUser;
function UserCard({ user }: { user: User }) {
return (
<div>
<strong>{user.name}</strong>
{user.type === 'full' ? <p>{user.bio}</p> : null}
</div>
);
}Ini lebih aman daripada mengandalkan 'bio' in user, selama field type memang ikut diserialisasi secara konsisten. Jika discriminator stabil, baik server maupun client cenderung memilih cabang render yang sama.
Checklist debugging hydration drift
Checklist cepat
- Bandingkan payload yang dipakai server dan client untuk komponen yang mismatch.
- Log hasil normalisasi props, bukan hanya raw response.
- Cari conditional rendering yang bergantung pada optional field atau shape detection.
- Periksa apakah ada field yang berubah dari objek menjadi string,
null, atau hilang setelah serialisasi. - Hindari
instanceofuntuk objek yang menyeberang batas serialisasi. - Pastikan default value di server dan client identik.
- Periksa formatter yang bergantung pada environment jika hasilnya dipakai di initial render.
- Verifikasi bahwa union type punya discriminator yang benar-benar stabil.
Langkah investigasi yang efektif
- Isolasi komponen: cari komponen terkecil yang memicu warning mismatch.
- Dump props server/client: cetak bentuk data sesaat sebelum render pada kedua sisi.
- Bandingkan keputusan cabang: misalnya nilai
hasDiscount,isFull, ataushowPreview. - Periksa layer transformasi: adapter API, serializer, cache, state hydration, dan helper formatting.
- Bekukan kontrak: ubah union mentah menjadi view model deterministik, lalu uji ulang.
Kesalahan umum yang memicu bug ini
- Mengandalkan optional field sebagai penentu struktur UI utama.
- Menganggap hasil narrowing di satu fungsi berlaku untuk data yang sudah melewati serialisasi.
- Mengirim instance class ke client dan tetap memakai method atau
instanceofsaat render. - Mencampur data mentah API dengan props presentasional tanpa normalisasi.
- Menyembunyikan masalah dengan client-only render padahal kontrak data tetap rusak.
Memindahkan komponen menjadi hanya dirender di client kadang menghilangkan warning, tetapi itu bukan perbaikan akar masalah. Jika kontrak data tidak jelas, bug bisa muncul lagi dalam bentuk lain: UI kosong, state tidak sinkron, atau cache yang sulit dipercaya.
Kapan harus memilih normalisasi di server, dan kapan di komponen?
Normalisasi di server atau data loader
Pilih ini jika:
- Data dipakai untuk render awal SSR.
- Banyak komponen bergantung pada kontrak yang sama.
- Anda ingin satu sumber kebenaran untuk bentuk props final.
Ini biasanya pilihan terbaik untuk mencegah hydration drift.
Guard di dalam komponen
Pilih ini jika:
- Komponen benar-benar reusable dan menerima data dari banyak sumber.
- Biaya normalisasi global terlalu besar.
- Anda tetap butuh fallback aman ketika kontrak dilanggar.
Namun jangan menjadikan komponen sebagai tempat utama menebak-nebak bentuk data yang seharusnya sudah pasti.
Ringkasan praktis
Hydration drift dari type narrowing di SSR UI biasanya bukan masalah ajaib di React atau Next.js. Penyebabnya sering lebih sederhana dan lebih berbahaya: server dan client tidak merender dari kontrak data runtime yang sama.
Pelajaran utamanya:
- Type narrowing membantu compiler, tetapi tidak menjamin kesetaraan bentuk data setelah serialisasi dan hidrasi.
- Generic bounds hanya memberi batas minimal; jangan infer bentuk konkret melebihi yang dijamin.
- Normalisasi props, guard runtime, dan serialisasi eksplisit membuat initial render lebih deterministik.
- Audit semua conditional rendering yang menentukan struktur DOM berdasarkan shape data.
Jika warning hydration muncul pada komponen yang bercabang dari bentuk data, perlakukan itu sebagai sinyal untuk mengaudit kontrak data lebih dulu. Sering kali solusi terbaik bukan mengutak-atik hydration, melainkan membuat data yang dirender server dan client menjadi benar-benar sama, jelas, dan stabil.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!