Hydration mismatch pada arsitektur full-stack Clojure/ClojureScript terjadi ketika struktur DOM hasil render backend via Hiccup berbeda dengan Virtual DOM yang dibangun Reagent saat inisialisasi di browser. React mendeteksi inkonsistensi atribut atau hierarki elemen, membatalkan penggabungan event listener secara parsial, lalu memicu render ulang penuh di client yang merusak performa.

Pada ClojureScript, penyebab utama mismatch ini bukan sekadar perbedaan markup HTML, melainkan perbedaan representasi data contract antara Clojure (JVM) dan ClojureScript (JS) saat serialisasi dan deserialisasi state aplikasi menggunakan Transit atau EDN.

Akar Masalah Desinkronisasi State EDN dan Transit

Saat melakukan Server-Side Rendering (SSR), backend memproses data struktur persisten Clojure murni (seperti records, namespaced keywords, dan instan waktu). Backend kemudian me-render komponen Hiccup ke dalam HTML statis dan menyematkan state yang sama ke dalam tag <script> agar dibaca oleh client.

Masalah muncul pada fase pembacaan payload tersebut di browser:

  • Namespaced Keyword Mismatch: Backend memformat nilai data menggunakan namespaced keyword seperti :account/status. Jika serialisasi JSON standar digunakan alih-alih Transit-JSON, keyword tersebut dikonversi menjadi string mentah "account/status" atau keyword tanpa namespace :status di client. Akibatnya, lookup map (:account/status state) di client menghasilkan nil.
  • Custom Record dan Tagged Literals: Record CLJ yang di-render di server sering kali gagal direkonstruksi di client jika CLJS tidak mendaftarkan handler pembaca Transit yang identik, sehingga fallback menjadi generic map biasa.
  • Format Temporal: Objek java.time.Instant di JVM sering kali terkonversi menjadi representasi string ISO yang berbeda interpretasi offset-nya dengan objek js/Date di browser, menghasilkan markup tanggal server dan client yang tidak identik.

Reproduksi Bug: Inkonsistensi Rendering

Pertimbangkan komponen badge status berikut yang dipakai bersama di CLJ dan CLJS:

(ns app.ui.badge)

(defn status-badge [{:keys [:user/tier]}]
  [:span {:class (case tier
                   :premium "badge-gold"
                   :free    "badge-gray"
                   "badge-unknown")}
   (name (or tier "unknown"))])

Di server (JVM), data dipasok langsung berupa map Clojure:

;; JVM SSR
(def initial-state {:user/tier :premium})
;; Hiccup me-render:
;; <span class="badge-gold">premium</span>

Jika payload dikirim via JSON konvensional (misal melalui cheshire.core tanpa preserving namespace), client membaca data sebagai {"tier": "premium"}. Di client:

;; CLJS Client Hydration
(get client-state :user/tier) ;; => nil
;; Reagent me-render:
;; <span class="badge-unknown">unknown</span>

React hydration langsung mendeteksi perbedaan class antara badge-gold dan badge-unknown, lalu memunculkan warning: "Hydration failed because the initial UI does not match what was rendered on the server."

Perbaikan 1: Simetri Transit Reader dan Writer

Gunakan Transit secara konsisten di kedua sisi untuk menjaga tipe data keyword namespaced, set, dan record tanpa degradasi tipe. Daftarkan custom reader/writer jika menggunakan custom type.

Backend Serializer (CLJ)

(ns app.server.ssr
  (:require [cognitect.transit :as transit]
            [hiccup2.core :as h]
            [app.ui.core :as ui])
  (:import [java.io ByteArrayOutputStream]))

(defn- state->transit-json [state]
  (let [out (ByteArrayOutputStream.)
        writer (transit/writer out :json)]
    (transit/write writer state)
    (.toString out "UTF-8")))

(defn render-page [initial-state]
  (let [app-html (h/html (ui/root-view initial-state))
        state-json (state->transit-json initial-state)]
    (str "<!DOCTYPE html>"
         (h/html
           [:html
            [:head [:title "App"]]
            [:body
             [:div#app (h/raw app-html)]
             [:script#app-state
              {:type "application/transit+json"}
              (h/raw state-json)]
             [:script {:src "/js/main.js"}]]]))))

Client Deserializer (CLJS)

(ns app.client.core
  (:require [cognitect.transit :as transit]
            [reagent.dom.client :as rdc]
            [app.ui.core :as ui]))

(defn- read-transit-state [element-id]
  (when-let [el (.getElementById js/document element-id)]
    (let [reader (transit/reader :json)]
      (transit/read reader (.-textContent el)))))

(defn ^:export init []
  (let [initial-state (read-transit-state "app-state")
        container (.getElementById js/document "app")]
    ;; Pastikan menggunakan hydrate-root, bukan create-root biasa
    (rdc/hydrate-root container [ui/root-view initial-state])))

Perbaikan 2: Normalisasi Data Contract Sebelum Hydration

Hindari passing objek yang memiliki parsing non-deterministik langsung ke view layer. Normalisasikan state ke dalam schema data primitif (primitif EDN) pada boundary controller sebelum diserahkan ke komponen Hiccup atau Transit writer.

(ns app.shared.contract
  (:require [clojure.spec.alpha :as s]))

(s/def :user/id string?)
(s/def :user/tier #{:premium :free})
(s/def :user/epoch-ms int?) ;; Hindari java.time.Instant langsung

(s/def ::state
  (s/keys :req [:user/id :user/tier :user/epoch-ms]))

(defn sanitize-state [raw-data]
  {:user/id (str (:id raw-data))
   :user/tier (keyword (:tier raw-data))
   :user/epoch-ms (inst-ms (:created_at raw-data))})

Perbaikan 3: Isolasi Komponen Dinamis Client-Only

Fitur yang bergantung pada API browser lokal (seperti js/window.innerWidth, localStorage, atau rendering tanggal berdasarkan timezone lokal user) dipastikan memicu hydration mismatch jika dirender di SSR. Komponen tersebut harus ditunda hingga proses hidrasi selesai.

(ns app.ui.common
  (:require [reagent.core :as r]))

(defn client-only
  "Komponen pembungkus untuk mencegah render non-deterministik saat SSR."
  [client-component fallback-component]
  (let [mounted? (r/atom false)]
    (r/create-class
      {:component-did-mount
       (fn [_] (reset! mounted? true))
       
       :reagent-render
       (fn [client-comp fallback-comp]
         (if @mounted?
           [client-comp]
           [fallback-comp]))})))

Verifikasi Deterministik State Roundtrip

Tulis unit test untuk memverifikasi bahwa state roundtrip dari CLJ ke CLJS tidak mengubah tipe data map, set, atau namespaced keyword.

(ns app.transit-test
  (:require [clojure.test :refer [deftest is]]
            [cognitect.transit :as transit])
  (:import [java.io ByteArrayInputStream ByteArrayOutputStream]))

(deftest test-state-preservation
  (let [original-state {:user/tier :premium
                        :settings/flags #{:dark-mode :beta}}
        out (ByteArrayOutputStream.)
        _ (transit/write (transit/writer out :json) original-state)
        in (ByteArrayInputStream. (.toByteArray out))
        restored (transit/read (transit/reader in :json))]
    (is (= original-state restored))
    (is (keyword-identical? :premium (:user/tier restored)))
    (is (contains? (:settings/flags restored) :dark-mode))))

Catatan: React 18+ memberlakukan validasi hydration yang lebih ketat. Gunakan atribut {:suppress-hydration-warning true} pada elemen Hiccup hanya jika terdapat perbedaan tak terelakkan seperti penanda timestamp rendering server, bukan sebagai workaround untuk menutupi kesalahan parsing data contract EDN.