Hydration mismatch pada UI Supabase Realtime muncul ketika markup yang dihasilkan server berbeda dari markup pada render pertama di browser. Penyebab umumnya adalah server merender snapshot data pada waktu tertentu, sementara client langsung membaca state terbaru, memasang subscription terlalu dini, atau menggunakan nilai yang hanya tersedia di browser.

Solusi yang aman adalah menjadikan snapshot SSR sebagai sumber state awal yang deterministik, memasang subscription Realtime setelah hydration selesai, lalu melakukan rekonsiliasi data awal dengan event yang masuk. Dengan pola ini, React melakukan hydration terhadap markup yang sama sebelum UI mulai merespons perubahan Realtime.

Memahami alur SSR ke client

Misalkan halaman menampilkan daftar tugas dari tabel tasks. Alur yang sehat terdiri dari beberapa tahap:

  1. Server mengambil snapshot data dari Supabase.
  2. Server merender HTML berdasarkan snapshot tersebut.
  3. Client melakukan render pertama menggunakan snapshot yang sama.
  4. Hydration selesai.
  5. Client memasang subscription Supabase Realtime.
  6. Perubahan berikutnya diterapkan melalui event Realtime atau hasil fetch ulang.

Secara konseptual, alurnya dapat digambarkan sebagai berikut:

t0: Server mengambil tasks = [A, B]
t1: Server mengirim HTML berdasarkan [A, B]
t2: Client melakukan render pertama dengan [A, B]
t3: Hydration selesai
t4: Client memasang subscription Realtime
t5: Event INSERT untuk C diterima
t6: UI diperbarui menjadi [A, B, C]

Masalah terjadi jika client pada t2 langsung mengambil data terbaru dan mendapatkan [A, B, C], sedangkan HTML server masih berisi [A, B]. React kemudian membandingkan dua markup berbeda dan melaporkan hydration mismatch.

Penyebab umum hydration mismatch

Snapshot SSR dan state client berbeda

Server dan browser mungkin mengambil data pada waktu yang berbeda. Sebuah baris dapat masuk atau berubah setelah SSR selesai, tetapi sebelum render pertama di browser. Perbedaan ini normal dari sudut pandang data, tetapi bermasalah jika perbedaan tersebut terjadi sebelum hydration.

Kesalahan yang sering terjadi adalah mengabaikan props hasil SSR dan selalu menjalankan fetch client pada inisialisasi state:

const [tasks, setTasks] = useState([]);

Server mungkin telah merender beberapa tugas, sedangkan client memulai dari array kosong. Sebaliknya, jika client langsung mengambil data terbaru sebelum hydration, client dapat merender lebih banyak baris daripada HTML server.

Subscription dipasang terlalu dini

Subscription yang dibuat saat modul dimuat, ketika komponen dievaluasi, atau sebelum render pertama dapat mengubah state sebelum hydration selesai. Event INSERT, UPDATE, atau DELETE yang cepat masuk dapat membuat client menghasilkan markup berbeda dari server.

Subscription sebaiknya dipasang di dalam useEffect. Effect berjalan setelah render dan hydration pada client, sehingga perubahan Realtime tidak mengubah markup yang sedang dicocokkan.

Event ganda atau handler ganda

Channel yang dibuat pada setiap render, effect tanpa dependency yang tepat, atau cleanup yang tidak dilakukan dapat menyebabkan satu event diproses lebih dari sekali. Dampaknya dapat berupa baris duplikat, urutan data yang berubah-ubah, dan perbedaan render yang sulit direproduksi.

Nilai yang hanya tersedia di browser

Nilai seperti localStorage, window.innerWidth, zona waktu lokal, waktu saat ini, dan Math.random() tidak boleh memengaruhi render pertama jika nilainya tidak sama di server dan client.

Contohnya, format tanggal berdasarkan zona waktu browser dapat menghasilkan teks berbeda dari format tanggal di server. Baca nilai browser-only setelah hydration, atau kirim nilai tersebut dari server sebagai data yang sudah ditentukan.

Pola state awal yang deterministik

Komponen client harus menerima data hasil SSR dan memakai data tersebut secara langsung sebagai initial state. Gunakan fungsi initializer agar nilai awal dibuat dari snapshot yang sama dan tidak mengambil data lain saat render.

"use client";

import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/browser";

type Task = {
  id: string;
  title: string;
  completed: boolean;
  updated_at: string;
};

type Props = {
  initialTasks: Task[];
};

export function TaskList({ initialTasks }: Props) {
  const [tasks, setTasks] = useState<Task[]>(() => initialTasks);
  const supabaseRef = useRef(createClient());

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          {task.title} {task.completed ? "✓" : "○"}
        </li>
      ))}
    </ul>
  );
}

Hal penting dalam contoh tersebut adalah initialTasks harus benar-benar sama dengan data yang digunakan server untuk menghasilkan HTML. Jangan memanggil Supabase, membaca storage, atau menghitung nilai acak di dalam render pertama.

Contoh komponen server

Detail pembuatan Supabase client server bergantung pada framework dan adapter yang digunakan. Prinsipnya tetap sama: ambil data sekali di sisi server, lalu teruskan hasilnya sebagai props ke komponen client.

export default async function TasksPage() {
  const { data, error } = await supabase
    .from("tasks")
    .select("id, title, completed, updated_at")
    .order("updated_at", { ascending: false });

  if (error) {
    throw error;
  }

  return <TaskList initialTasks={data ?? []} />;
}

Jika data tidak tersedia, gunakan fallback yang konsisten, misalnya [] di server dan client. Hindari server menggunakan null sementara client mengubahnya menjadi array sebelum hydration.

Memasang subscription setelah hydration

Gunakan useEffect dengan dependency yang stabil dan hapus channel ketika komponen unmount. Client Supabase sebaiknya berupa singleton atau instance yang stabil, bukan dibuat ulang pada setiap render.

useEffect(() => {
  const supabase = supabaseRef.current;
  const channel = supabase
    .channel("tasks-realtime")
    .on(
      "postgres_changes",
      {
        event: "INSERT",
        schema: "public",
        table: "tasks"
      },
      (payload) => {
        const task = payload.new as Task;
        setTasks((current) => {
          if (current.some((item) => item.id === task.id)) {
            return current;
          }
          return [task, ...current];
        });
      }
    )
    .on(
      "UPDATE",
      {
        event: "UPDATE",
        schema: "public",
        table: "tasks"
      },
      (payload) => {
        const task = payload.new as Task;
        setTasks((current) =>
          current.map((item) => (item.id === task.id ? task : item))
        );
      }
    )
    .on(
      "postgres_changes",
      {
        event: "DELETE",
        schema: "public",
        table: "tasks"
      },
      (payload) => {
        const deleted = payload.old as { id: string };
        setTasks((current) =>
          current.filter((item) => item.id !== deleted.id)
        );
      }
    )
    .subscribe();

  return () => {
    void supabase.removeChannel(channel);
  };
}, []);

Perhatikan bahwa handler menggunakan bentuk fungsional setTasks(current => ...). Cara ini mencegah handler membaca closure state yang sudah kedaluwarsa ketika beberapa event masuk berdekatan.

Nama event untuk setiap handler harus konsisten. Pada API Supabase Realtime, filter perubahan database menggunakan event seperti INSERT, UPDATE, dan DELETE dalam konfigurasi postgres_changes. Jangan mencampur konfigurasi event sehingga handler menerima payload yang tidak sesuai.

Rekonsiliasi snapshot awal dengan event Realtime

Memasang subscription setelah hydration mencegah mismatch, tetapi memperkenalkan celah waktu: perubahan dapat terjadi setelah snapshot SSR dibuat dan sebelum subscription aktif. Karena itu, subscription bukan selalu pengganti fetch data awal.

Pola yang praktis adalah:

  1. Render dengan snapshot SSR.
  2. Pasang channel di dalam effect.
  3. Setelah channel berhasil berlangganan, ambil snapshot terbaru.
  4. Ganti state dengan hasil fetch terbaru.
  5. Gunakan handler idempoten untuk event yang datang sesudahnya.

Contoh fungsi rekonsiliasi:

async function loadLatestTasks() {
  const { data, error } = await supabaseRef.current
    .from("tasks")
    .select("id, title, completed, updated_at")
    .order("updated_at", { ascending: false });

  if (error) {
    console.error("Gagal memuat ulang tasks", error);
    return;
  }

  setTasks(data ?? []);
}

Dalam implementasi nyata, panggil fungsi tersebut setelah status subscription berhasil. Callback status dapat berbeda menurut versi library dan adapter, jadi gunakan status yang didokumentasikan oleh versi client yang dipakai aplikasi.

Ada kondisi balapan antara fetch rekonsiliasi dan event Realtime: event baru dapat diproses, lalu hasil fetch yang lebih lama menimpa state. Untuk kebutuhan yang sensitif terhadap urutan, tambahkan aturan versi, misalnya membandingkan updated_at atau kolom revisi monotonik sebelum menerapkan perubahan.

function isNewer(incoming: Task, current: Task) {
  return incoming.updated_at >= current.updated_at;
}

setTasks((current) =>
  current.map((item) =>
    item.id === incoming.id && isNewer(incoming, item)
      ? incoming
      : item
  )
);

Perbandingan timestamp hanya aman jika formatnya konsisten dan memiliki presisi yang memadai. Untuk sistem dengan banyak penulis atau kebutuhan ordering yang ketat, gunakan revision number dari database atau lakukan fetch ulang setelah operasi penting.

Menangani nilai browser-only tanpa mismatch

Jika UI memang memerlukan informasi browser, render placeholder yang sama di server dan pada render pertama client. Setelah effect berjalan, barulah tampilkan nilai sebenarnya.

function ViewportInfo() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener("resize", update);

    return () => window.removeEventListener("resize", update);
  }, []);

  return <span>{width === null ? "—" : `${width}px`}</span>;
}

Pola yang sama dapat digunakan untuk localStorage, timezone browser, dan preferensi tampilan. Jangan mengatasi masalah hanya dengan menonaktifkan peringatan hydration. Suppression menyembunyikan gejala, tetapi tidak memperbaiki perbedaan state atau potensi UI yang salah.

Cleanup channel dan pencegahan event ganda

Cleanup wajib dilakukan ketika komponen unmount atau ketika dependency subscription berubah. Tanpa cleanup, navigasi antarhalaman dapat meninggalkan channel lama. Akibatnya satu perubahan diterapkan oleh beberapa handler.

  • Buat channel di dalam effect, bukan pada body render.
  • Gunakan dependency array yang benar.
  • Simpan instance client secara stabil.
  • Panggil removeChannel pada cleanup.
  • Buat operasi INSERT dan UPDATE idempoten berdasarkan primary key.
  • Jangan menambahkan item tanpa memeriksa apakah ID tersebut sudah ada.

React Strict Mode pada lingkungan pengembangan dapat menjalankan siklus effect tambahan untuk mendeteksi side effect yang tidak aman. Jika cleanup benar, perilaku ini tidak menyebabkan channel permanen atau event ganda. Jangan menjadikan Strict Mode sebagai alasan untuk menghapus cleanup.

Strategi pengujian dan debugging

Checklist diagnosis

  • Apakah HTML server dan render pertama client memakai array data yang sama?
  • Apakah ada fetch Supabase atau pembacaan window di dalam render?
  • Apakah subscription dibuat sebelum useEffect?
  • Apakah channel dibuat ulang setiap kali komponen render?
  • Apakah cleanup memanggil removeChannel?
  • Apakah handler INSERT dapat menambahkan ID yang sama lebih dari sekali?
  • Apakah timezone, waktu saat ini, random value, atau localStorage memengaruhi markup awal?
  • Apakah fetch rekonsiliasi dapat menimpa event yang lebih baru?

Uji dengan kondisi data yang berubah cepat

Uji setidaknya skenario berikut:

  1. Server merender daftar kosong, lalu sebuah row dibuat sebelum subscription aktif.
  2. Row di-update beberapa kali secara cepat.
  3. INSERT dan DELETE terjadi ketika halaman baru selesai hydration.
  4. Pengguna berpindah halaman lalu kembali, untuk memastikan channel lama sudah dihapus.
  5. Browser menggunakan timezone atau ukuran viewport yang berbeda dari lingkungan server.
  6. Hydration dijalankan dalam mode pengembangan dengan Strict Mode.

Tambahkan logging sementara untuk waktu SSR, waktu effect, status subscription, ID channel, dan primary key payload. Log tersebut membantu membedakan mismatch karena snapshot dari masalah lifecycle subscription.

console.debug("tasks effect mounted");
console.debug("realtime payload", {
  event: payload.eventType,
  id: payload.new?.id ?? payload.old?.id
});

return () => {
  console.debug("tasks channel removed");
  void supabase.removeChannel(channel);
};

Untuk pengujian otomatis, verifikasi bahwa render pertama menerima initialTasks, tidak membuat subscription sebelum effect, handler tidak menggandakan ID, dan cleanup memanggil penghapusan channel. Pengujian end-to-end dapat membuat perubahan database setelah halaman terbuka, lalu memastikan UI diperbarui tanpa console error hydration.

Ringkasan pola implementasi

Pola yang paling stabil untuk UI Supabase Realtime adalah menggunakan SSR sebagai snapshot awal, bukan sebagai state yang terus dianggap mutakhir. Render pertama client harus deterministik dan identik dengan HTML server. Setelah hydration selesai, pasang satu subscription yang lifecycle-nya jelas, lakukan fetch ulang untuk menutup celah antara snapshot dan subscription, lalu terapkan event secara idempoten.

Prinsip utama: jangan biarkan data Realtime, nilai browser-only, atau fetch client mengubah markup sebelum hydration selesai.