Hydration mismatch pada komponen select tree yang dirender lewat SSR hampir selalu terjadi karena HTML hasil render server tidak identik dengan render awal di client. Pada struktur taxonomy, masalah ini sering dipicu oleh data node yang tidak seragam, urutan anak yang tidak deterministik, label fallback yang berubah, atau selected state yang dihitung berbeda di dua sisi.

Jika Anda sedang menangani taxonomy tree seperti pola data JavaScript yang umum dipakai pada dataset hierarkis—misalnya node dengan id, name, children, alias, dan metadata—maka target utamanya sederhana: pastikan input render pertama di server dan client benar-benar sama. Artikel ini fokus pada cara praktis mencegah mismatch, bukan sekadar menutupi warning.

Mengapa select tree SSR rentan terhadap hydration mismatch

Komponen tree lebih rentan daripada dropdown datar karena ia menggabungkan beberapa sumber ketidakstabilan sekaligus:

  • Data hierarkis: node induk dan anak dapat datang dari hasil transformasi yang berbeda.
  • Render rekursif: perbedaan kecil pada satu cabang bisa mengubah seluruh subtree HTML.
  • State turunan: item terpilih, node terbuka, breadcrumb, dan label sering dihitung dari URL, query, local storage, atau hasil fetch tambahan.
  • Sorting implisit: urutan object key atau hasil sort() yang tidak konsisten akan mengubah urutan DOM.

Gejala UI yang umum:

  • Warning seperti Text content does not match server-rendered HTML.
  • Node terpilih di server berbeda dengan yang terlihat setelah hydration.
  • Cabang tree terbuka di server, tetapi menutup atau berpindah di client.
  • Label placeholder berubah dari fallback ke nilai lain saat hydration.
  • Event handler terpasang pada node yang salah karena struktur list bergeser.

Akar masalah umum pada data taxonomy

1. Bentuk data node tidak konsisten

Pada data taxonomy nyata, tidak semua node selalu punya properti yang sama. Ada node yang punya label, yang lain hanya name, ada yang children-nya undefined, ada yang array kosong. Bila server merender dengan fallback name tetapi client memilih label setelah normalisasi lain, hasil HTML akan berbeda.

Masalah yang sering terjadi:

  • children: undefined di server, tetapi children: [] di client.
  • Node tertentu tidak punya label utama, sehingga fallback dihitung dari field berbeda.
  • ID sebagian node berupa number di satu sisi dan string di sisi lain.

2. Urutan anak tidak deterministik

Jika children berasal dari object yang diubah menjadi array, dari gabungan beberapa sumber, atau dari sort() yang tidak memiliki pembanding eksplisit, maka urutan hasil render bisa berubah. Pada SSR, perbedaan urutan satu level saja cukup untuk menghasilkan mismatch.

Contoh sumber ketidakstabilan:

  • Mengandalkan urutan insertion object.
  • array.sort() tanpa comparator yang konsisten.
  • Comparator memakai locale, null handling, atau fallback yang berbeda antara server dan browser.
  • Sorting berdasarkan data yang baru lengkap di client.

3. Selected state dihitung dari sumber yang berbeda

Node terpilih kadang dihitung dari query URL, cookie, local storage, hasil API, atau state global. Bila server memakai parameter route, sedangkan client mengganti selected node dari local storage saat render pertama, struktur tree dapat berubah sebelum hydration selesai.

Kasus yang umum:

  • Server memilih node berdasarkan slug route, client memilih berdasarkan cache terakhir.
  • Server belum tahu preferensi pengguna, client langsung memakainya di render awal.
  • Node aktif dihitung dari path yang dinormalisasi berbeda.

4. Key React tidak stabil

Menggunakan index array sebagai key pada tree adalah cara cepat menuju bug. Begitu urutan anak berubah, React akan menganggap node lama adalah node baru. Pada hydration, ini bisa memperparah mismatch atau membuat state lokal subtree berpindah ke item lain.

5. Render pertama bergantung pada API browser

Jika label fallback atau selected state ditentukan dengan membaca window, localStorage, timezone browser, atau ukuran viewport sebelum hydration selesai, output client akan berbeda dari server.

Checklist audit untuk tim frontend

Sebelum mengubah banyak kode, audit pipeline data dan render dengan checklist berikut:

  1. Apakah data taxonomy yang dipakai server dan client berasal dari sumber yang sama?
  2. Apakah seluruh node dinormalisasi ke bentuk yang identik sebelum render?
  3. Apakah semua ID dipaksa ke tipe yang sama, misalnya string?
  4. Apakah urutan children disortir dengan comparator yang deterministik?
  5. Apakah fallback label didefinisikan satu kali dan dipakai di kedua sisi?
  6. Apakah selected state awal berasal dari input SSR-safe, bukan dari local storage?
  7. Apakah React key menggunakan ID stabil, bukan index?
  8. Apakah tree interaktif ditunda sampai client siap bila data awal belum pasti?

Prinsipnya: server render pertama dan client render pertama harus memakai fungsi normalisasi yang sama, dengan input yang sama, dan output urutan yang sama.

Strategi normalisasi data sebelum render

Untuk taxonomy tree, normalisasi sebaiknya dilakukan sebelum komponen render. Jangan biarkan komponen UI memutuskan sendiri fallback field, urutan anak, atau tipe ID.

Bentuk node yang disarankan

Anda tidak perlu mengikuti skema tertentu secara kaku, tetapi bentuk internal harus stabil. Misalnya:

type RawNode = {
  id?: string | number;
  name?: string;
  label?: string;
  slug?: string;
  children?: RawNode[];
};

type TaxonomyNode = {
  id: string;
  label: string;
  slug: string | null;
  children: TaxonomyNode[];
};

Tujuannya adalah mengubah semua variasi input ke satu bentuk final yang aman untuk SSR.

Fungsi normalisasi yang deterministik

function normalizeLabel(node) {
  if (typeof node.label === 'string' && node.label.trim()) return node.label.trim();
  if (typeof node.name === 'string' && node.name.trim()) return node.name.trim();
  return 'Tanpa label';
}

function normalizeId(node, parentId = 'root', index = 0) {
  if (node.id !== undefined && node.id !== null) return String(node.id);
  return parentId + ':' + index;
}

function byStableLabel(a, b) {
  const labelCompare = a.label.localeCompare(b.label, 'id', { sensitivity: 'base' });
  if (labelCompare !== 0) return labelCompare;
  return a.id.localeCompare(b.id);
}

function normalizeTree(rawNodes, parentId = 'root') {
  const safeNodes = Array.isArray(rawNodes) ? rawNodes : [];

  const normalized = safeNodes.map((node, index) => {
    const id = normalizeId(node, parentId, index);
    const label = normalizeLabel(node);
    const slug = typeof node.slug === 'string' && node.slug.trim() ? node.slug.trim() : null;
    const children = normalizeTree(node.children, id);

    return { id, label, slug, children };
  });

  normalized.sort(byStableLabel);
  return normalized;
}

Mengapa pendekatan ini bekerja:

  • ID dipaksa stabil: semua menjadi string.
  • Label fallback tunggal: tidak ada logika fallback yang tersebar di banyak komponen.
  • Children selalu array: render rekursif tidak perlu bercabang berdasarkan undefined vs array kosong.
  • Sorting eksplisit: hasil urutan tidak bergantung pada sumber data.

Catatan penting: fallback ID berbasis parentId:index hanya aman bila urutan input mentah juga stabil. Jika urutan sumber mentah bisa berubah, Anda perlu identifier yang benar-benar persisten dari backend atau proses build data. Bila tidak ada, hydration mismatch hanya akan dipindahkan ke tempat lain.

Kunci ID stabil dan kenapa index tidak cukup

Pada tree, key={index} sering tampak baik-baik saja sampai anak diurutkan ulang, node baru disisipkan, atau selected state berubah. Gunakan ID domain yang stabil dan persisten.

function TreeNode({ node, selectedId, onSelect }) {
  const isSelected = node.id === selectedId;

  return (
    <li>
      <button
        type="button"
        aria-pressed={isSelected}
        onClick={() => onSelect(node.id)}
      >
        {node.label}
      </button>

      {node.children.length > 0 && (
        <ul>
          {node.children.map((child) => (
            <TreeNode
              key={child.id}
              node={child}
              selectedId={selectedId}
              onSelect={onSelect}
            />
          ))}
        </ul>
      )}
    </li>
  );
}

Jika backend belum menyediakan ID yang stabil, lebih baik perbaiki kontrak data terlebih dahulu daripada menebak di UI. Untuk taxonomy seperti kategori, topik, atau area klasifikasi, identifier stabil adalah kebutuhan dasar, bukan optimasi tambahan.

Sorting deterministik untuk mencegah DOM bergeser

Sorting harus didefinisikan sebagai bagian dari kontrak render. Jangan menganggap urutan data sudah benar kecuali memang dijamin oleh sumbernya.

Praktik yang aman

  • Selalu gunakan comparator eksplisit.
  • Tambahkan tie-breaker, misalnya id, jika dua label sama.
  • Normalisasi whitespace, case, dan null lebih dulu sebelum sort.
  • Lakukan sort di satu tempat, idealnya saat normalisasi data.

Kesalahan yang sering lolos review

  • Menyortir di server, lalu menyortir ulang dengan aturan berbeda di client.
  • Comparator yang bergantung pada field opsional yang kadang kosong.
  • Menggabungkan hasil dari beberapa endpoint tanpa sort final.

Bila Anda memakai dataset taxonomy dari pola JavaScript seperti yang umum ditemukan pada repositori referensi struktur kategori, jangan langsung mengasumsikan urutan input aman untuk SSR. Jadikan normalisasi + sort sebagai langkah eksplisit.

Default state yang aman untuk SSR

Selected state, node terbuka, dan filter aktif harus punya nilai awal yang aman di server. Jika state awal belum bisa ditentukan secara identik di dua sisi, pilih salah satu dari dua pendekatan berikut:

1. Gunakan state awal yang sepenuhnya diturunkan dari SSR input

Contohnya, selected node ditentukan hanya dari parameter route atau props hasil loader server.

function findSelectedId(nodes, slug) {
  for (const node of nodes) {
    if (node.slug === slug) return node.id;
    const nested = findSelectedId(node.children, slug);
    if (nested) return nested;
  }
  return null;
}

Dengan cara ini, server dan client sama-sama menghitung nilai awal dari data yang sama.

2. Render default netral, lalu upgrade setelah client siap

Jika selected state harus membaca local storage atau preferensi browser, jangan pakai nilai itu saat render pertama. Render dulu state netral yang identik dengan SSR, lalu ubah setelah mount.

import { useEffect, useMemo, useState } from 'react';

function TaxonomySelectTree({ rawNodes, initialSlug }) {
  const nodes = useMemo(() => normalizeTree(rawNodes), [rawNodes]);
  const ssrSelectedId = useMemo(() => findSelectedId(nodes, initialSlug), [nodes, initialSlug]);

  const [selectedId, setSelectedId] = useState(ssrSelectedId);
  const [isClientReady, setIsClientReady] = useState(false);

  useEffect(() => {
    setIsClientReady(true);
    const persisted = window.localStorage.getItem('taxonomy:selectedId');
    if (persisted) setSelectedId(persisted);
  }, []);

  useEffect(() => {
    if (isClientReady && selectedId) {
      window.localStorage.setItem('taxonomy:selectedId', selectedId);
    }
  }, [isClientReady, selectedId]);

  return (
    <ul>
      {nodes.map((node) => (
        <TreeNode key={node.id} node={node} selectedId={selectedId} onSelect={setSelectedId} />
      ))}
    </ul>
  );
}

Pendekatan ini mencegah mismatch karena render pertama client masih sama dengan SSR. Perubahan baru terjadi setelah hydration selesai.

Kapan menunda render interaktif sampai client siap

Tidak semua bagian tree harus dipaksa SSR penuh. Jika interaktivitas awal benar-benar bergantung pada data browser-only, Anda bisa menunda render bagian interaktifnya.

Tunda interaksi, bukan selalu seluruh markup

Pilihan terbaik sering kali adalah:

  • SSR untuk struktur statis dan label dasar.
  • Tunda perilaku yang bergantung pada browser, seperti expanded state personal, keyboard roving index, atau pilihan tersimpan pengguna.

Dengan begitu, SEO dan waktu tampil awal tetap baik, tetapi hydration tetap stabil.

Contoh pola aman di Next.js/React

import { useEffect, useMemo, useState } from 'react';

export default function TaxonomyTree({ rawNodes, initialSlug }) {
  const nodes = useMemo(() => normalizeTree(rawNodes), [rawNodes]);
  const initialSelectedId = useMemo(() => findSelectedId(nodes, initialSlug), [nodes, initialSlug]);
  const [hydrated, setHydrated] = useState(false);
  const [selectedId, setSelectedId] = useState(initialSelectedId);

  useEffect(() => {
    setHydrated(true);
  }, []);

  return (
    <div>
      {!hydrated && <p>Memuat status pilihan...</p>}
      <ul aria-busy={!hydrated}>
        {nodes.map((node) => (
          <TreeNode
            key={node.id}
            node={node}
            selectedId={selectedId}
            onSelect={setSelectedId}
          />
        ))}
      </ul>
    </div>
  );
}

Trade-off pendekatan ini:

  • Kelebihan: mismatch turun drastis, logika browser-only terisolasi.
  • Kekurangan: ada fase transisi sebelum interaksi penuh aktif.

Jika perbedaan data sangat besar antara server dan client, lebih baik tunda komponen interaktif daripada memaksa hydration pada markup yang tidak stabil.

Langkah debug yang bisa diterapkan tim frontend

1. Bandingkan snapshot data server vs client

Log atau serialisasikan hasil normalizeTree(rawNodes) di server dan di client render awal. Fokus pada:

  • jumlah node,
  • urutan sibling,
  • nilai id,
  • nilai label,
  • selected ID awal.

Jangan hanya membandingkan data mentah; yang penting adalah data yang benar-benar dipakai untuk render.

2. Tambahkan assertion untuk kontrak data

function assertTree(nodes, path = 'root') {
  for (const node of nodes) {
    if (typeof node.id !== 'string') {
      throw new Error('Invalid node.id at ' + path);
    }
    if (typeof node.label !== 'string' || !node.label) {
      throw new Error('Invalid node.label at ' + path + ':' + node.id);
    }
    if (!Array.isArray(node.children)) {
      throw new Error('Invalid node.children at ' + path + ':' + node.id);
    }
    assertTree(node.children, path + ':' + node.id);
  }
}

Assertion sederhana sering menemukan sumber mismatch lebih cepat daripada membaca warning hydration yang generik.

3. Bekukan urutan sebelum render

Jika tim mencurigai urutan berubah di client, log daftar sibling sebagai pasangan id-label tepat sebelum render. Ini lebih mudah dianalisis daripada memeriksa HTML panjang.

4. Audit semua fallback label

Cari pola seperti berikut di codebase:

  • node.label || node.name
  • node.name ?? node.title
  • children?.length tanpa normalisasi sebelumnya

Fallback yang tersebar di banyak file adalah sumber perbedaan perilaku antara server dan client.

5. Uji dengan data yang benar-benar tak seragam

Jangan hanya menguji dataset bersih. Buat fixture yang berisi:

  • node tanpa label,
  • ID number dan string campur,
  • children kosong dan undefined,
  • label duplikat,
  • urutan input acak.

Jika tree Anda lolos pada fixture seperti ini, peluang mismatch di produksi jauh lebih kecil.

6. Pisahkan bug hydration dari bug state sesudah mount

Hydration mismatch terjadi saat render awal client tidak cocok dengan SSR. Kalau UI berubah setelah useEffect, itu belum tentu hydration bug. Karena itu, bedakan dua fase:

  • Pre-hydration / initial client render: harus identik dengan SSR.
  • Post-mount update: boleh berubah jika memang disengaja.

Pola implementasi yang disarankan

  1. Ambil data taxonomy mentah dari satu sumber yang sama untuk SSR dan client.
  2. Normalisasi seluruh node dengan satu fungsi murni.
  3. Paksa ID ke string dan children ke array.
  4. Tentukan fallback label di satu tempat.
  5. Sort sibling dengan comparator deterministik dan tie-breaker.
  6. Hitung selected state awal hanya dari input SSR-safe.
  7. Gunakan ID stabil sebagai React key.
  8. Tunda state browser-only sampai komponen selesai mount.

Urutan ini penting. Banyak tim mencoba memperbaiki warning hydration di level komponen, padahal akar masalah ada pada kontrak data.

Kesalahan umum yang sebaiknya dihindari

  • Menggunakan Math.random(), timestamp, atau UUID baru saat render untuk key atau ID fallback.
  • Mengandalkan local storage pada render pertama untuk selected state.
  • Menyortir di beberapa tempat dengan aturan berbeda.
  • Membiarkan komponen UI memutuskan fallback data sendiri-sendiri.
  • Menganggap mismatch selesai hanya karena warning hilang, padahal state subtree sudah salah asosiasi.

Penutup

Untuk Debug SSR Select Tree: Hydration Stabil, solusi utamanya bukan trik React, melainkan disiplin pada bentuk data dan determinisme render. Pada taxonomy tree, perbedaan kecil seperti fallback label, tipe ID, atau urutan child sudah cukup untuk membuat server dan client menghasilkan DOM yang berbeda.

Jika tim Anda menangani data taxonomy yang tidak sepenuhnya seragam, lakukan tiga hal ini lebih dulu: normalisasi sebelum render, gunakan key berbasis ID stabil, dan tetapkan selected state awal yang aman untuk SSR. Bila interaktivitas masih bergantung pada data browser-only, tunda upgrade interaktif sampai client siap. Dengan pola ini, hydration warning berkurang bukan karena disembunyikan, tetapi karena akar mismatch benar-benar dihilangkan.