Hydration mismatch terjadi ketika representasi Virtual DOM (VDOM) yang dihasilkan klien JavaScript (seperti React, Vue, atau Alpine.js) tidak identik dengan dokumen HTML hasil Server-Side Rendering (SSR). Masalah ini paling sering dipicu oleh pemformatan tanggal dan waktu. Server mengeksekusi format string berbasis konfigurasi global backend, sedangkan browser klien melakukan hidrasi menggunakan zona waktu lokal pengguna dan locale bawaan peramban.

Akar Masalah: Perbedaan State Server vs Klien

Django merender template di server dengan parameter lokal tertentu, umumnya dikontrol melalui TIME_ZONE dan USE_I18N pada settings.py. Jika Django menyetel zona waktu ke UTC atau Asia/Jakarta, string HTML yang keluar akan merefleksikan nilai tersebut.

<!-- Hasil render Django di server -->
<span class="timestamp">04/05/2024 14:00</span>

Ketika frontend framework (misalnya React) menginisiasi proses hidrasi, komponen mengeksekusi kode formatting berbasis JavaScript murni seperti new Date().toLocaleString() atau Intl.DateTimeFormat. Jika browser klien berada di London (Europe/London) atau Tokyo (Asia/Tokyo), output string hasil komputasi klien berbeda dengan teks di dalam DOM yang dikirim server.

// Output VDOM React di klien (Locale en-US, browser di GMT+1)
<span class="timestamp">5/4/2024, 3:00 PM</span>

Perbedaan string teks teks node memicu React melempar error: "Hydration failed because the initial UI does not match what was rendered on the server". Vue dan framework modern lainnya juga akan membatalkan hidrasi parsial atau memaksa re-render penuh dari awal, menyebabkan performa anjlok dan visual flicker.

Pondasi Semantik: Menggunakan Elemen <time>

Jangan render teks tanggal lokal secara mentah langsung dari Django ke dalam komponen reaktif jika teks tersebut akan diubah di klien. Manfaatkan standar native HTML: elemen <time> dengan atribut datetime berspesifikasi ISO 8601 UTC.

Format ISO 8601 adalah representasi universal yang tidak ambigu. Server bertugas menyediakan data kanonikal, sementara klien bertugas memformat tampilan antarmuka.

Filter Django untuk Konversi UTC ISO 8601

Django menyediakan filter bawaan date:"c", namun filter tersebut merender waktu berdasarkan active timezone server. Buat custom template filter untuk menjamin konversi eksplisit ke UTC tanpa offset dinamis lokal server.

# app/templatetags/date_tags.py
from django import template
from django.utils import timezone
import datetime

register = template.Library()

@register.filter(name="iso_utc")
def iso_utc(value):
    """Konversi datetime objek ke string ISO 8601 UTC murni."""
    if not isinstance(value, (datetime.datetime, datetime.date)):
        return ""
    if isinstance(value, datetime.date) and not isinstance(value, datetime.datetime):
        return value.isoformat()
    if timezone.is_naive(value):
        value = timezone.make_aware(value, timezone.utc)
    else:
        value = value.astimezone(datetime.timezone.utc)
    # ponytail: upgrade ke datetime.timezone.utc bawaan Python 3.11+
    return value.strftime("%Y-%m-%dT%H:%M:%SZ")
Alternatif instan tanpa custom code: gunakan bawaan Django {% load tz %}{% timezone "UTC" %}{{ item.created_at|date:"c" }}{% endtimezone %} langsung pada template.

Penundaan Rendering di Klien (Two-Pass Render)

Kunci mencegah hydration mismatch adalah memastikan output render pertama pada klien 100% sama dengan HTML dari server. Nilai lokal pengguna baru disuntikkan pada siklus render kedua setelah hidrasi selesai.

1. Pola Implementasi pada React

// LocalTime.tsx
import { useState, useEffect } from "react";

interface Props {
  isoDate: string;
  fallbackText: string;
}

export function LocalTime({ isoDate, fallbackText }: Props) {
  const [isHydrated, setIsHydrated] = useState(false);

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

  const formattedDate = isHydrated
    ? new Intl.DateTimeFormat(navigator.language, {
        dateStyle: "medium",
        timeStyle: "short",
      }).format(new Date(isoDate))
    : fallbackText;

  return <time dateTime={isoDate}>{formattedDate}</time>;
}

2. Implementasi pada Alpine.js

Jika menggunakan Django dengan stack HTMX/Alpine.js, hindari rendering nilai dinamis di dalam node template teks langsung. Gunakan binding atribut x-text yang dievaluasi setelah DOM selesai dimuat (post-mount).

<!-- Template Django -->
{% load date_tags %}
<time
  datetime="{{ post.published_at|iso_utc }}"
  x-data="{
    init() {
      const raw = $el.getAttribute('datetime');
      $el.textContent = new Intl.DateTimeFormat(navigator.language, {
        dateStyle: 'medium',
        timeStyle: 'short'
      }).format(new Date(raw));
    }
  }"
>{{ post.published_at|iso_utc }}</time>

Pengujian Validasi Tanggal Backend

Verifikasi bahwa output filter Django selalu valid dan tidak menyertakan local drift sebelum diteruskan ke mesin hidrasi frontend.

# tests/test_date_tags.py
import unittest
from datetime import datetime, timezone
from app.templatetags.date_tags import iso_utc

class TestDateTags(unittest.TestCase):
    def test_iso_utc_output_format(self):
        dt = datetime(2024, 5, 4, 14, 0, 0, tzinfo=timezone.utc)
        result = iso_utc(dt)
        self.assertEqual(result, "2024-05-04T14:00:00Z")

if __name__ == "__main__":
    unittest.main()

Trade-off dan Keputusan Arsitektur

  • Two-Pass Render vs suppressHydrationWarning: React mendukung atribut suppressHydrationWarning pada tag teks. Gunakan atribut ini hanya untuk kasus sederhana di mana layout shift tidak mengganggu. Untuk integrasi skala besar, two-pass rendering memastikan stabilitas DOM tree.
  • SEO vs Lokalisasi: Search engine crawler membaca output teks SSR (UTC/fallback string). Menggunakan elemen semantik <time datetime="..."> menjamin bot mesin pencari tetap memahami waktu entitas yang tepat melalui metadata machine-readable.
  • Cumulative Layout Shift (CLS): Perbedaan panjang karakter antara fallback string SSR dan string lokal klien dapat menyebabkan layout bergeser. Antisipasi dengan menyamakan format struktur fallback sedekat mungkin dengan output target (misalnya: YYYY-MM-DD HH:mm UTC).