Hydration error atau render mismatch sering terjadi pada UI berbasis grid atau slot dinamis seperti inventory game, builder layout, atau dashboard widget. Masalah ini kerap berakar dari asumsi simetri palsu (false symmetry assumption) pada level data model: struktur visual tampak simetris (misalnya slot kiri dan kanan), tetapi representasi state di memori disusun secara implisit menggunakan array index dengan offset manual.

Pola kegagalan ini mirip dengan left arm bug pada MechCommander, di mana data hardpoint untuk komponen simetris diasumsikan berurutan secara seragam. Saat runtime mengevaluasi array dengan offset kaku antara state SSR dan rehydration di browser, rendering pohon DOM menghasilkan pohon node yang tidak sinkron.

Root Cause: Index Offset Asimetris pada State Tree

Saat server merender komponen React/Next.js ke HTML, engine SSR membangun virtual DOM tree berdasarkan serialisasi state awal. Jika state inventory disimpan dalam flat array tanpa relasi ID eksplisit, mapping elemen bergantung penuh pada indeks iterasi.

Masalah muncul ketika mutasi client-side terjadi sebelum atau selama rehydration (misal: lazy-loading equip slot, filter ketersediaan item, atau pembagian array ganjil-genap). Jika server menganggap indeks 0 adalah Slot Kiri dan indeks 1 adalah Slot Kanan, tetapi client mengevaluasi array yang terfilter atau terurut berbeda, React reconciliation akan memetakan atribut DOM dari satu slot ke node slot lain.

SSR Tree:   [Index 0: Arm_L] -> <div slot="left">Laser</div>
Client DOM: [Index 0: Arm_R] -> <div slot="right">Shield</div> // MISMATCH

Contoh Kasus: Bug Asymmetric State pada Slot Component

Kode di bawah mendemonstrasikan pola anti-pattern: membagi satu array mentah menjadi dua slot simetris menggunakan hardcoded slice dan array index sebagai React key.

// components/InventorySlots.tsx
"use client";

import React, { useState } from "react";

interface SlotItem {
  name: string;
  type: "weapon" | "armor";
}

export function BuggyInventory({ initialSlots }: { initialSlots: SlotItem[] }) {
  const [slots] = useState<SlotItem[]>(initialSlots);

  // BUG: Mengasumsikan slot selalu genap dan urutan array server === client
  const leftArm = slots.slice(0, slots.length / 2);
  const rightArm = slots.slice(slots.length / 2);

  return (
    <div className="grid grid-cols-2 gap-4">
      <div className="slot-column-left">
        {leftArm.map((item, index) => (
          // Anti-pattern: Index offset membuat React tertukar saat hydrate
          <div key={index} className="slot-node" data-slot-id={`left-${index}`}>
            {item.name}
          </div>
        ))}
      </div>
      <div className="slot-column-right">
        {rightArm.map((item, index) => (
          <div key={index} className="slot-node" data-slot-id={`right-${index}`}>
            {item.name}
          </div>
        ))}
      </div>
    </div>
  );
}

Jika initialSlots mengalami parsing kondisional atau lazy hydration pada browser, node DOM sisi kiri dapat menerima data sisi kanan tanpa memicu full DOM recreation, meninggalkan UI dalam status rusak permanen.

Solusi 1: Normalisasi Skema State Berbasis ID Unik

Hapus ketergantungan pada array index dan array partitioning manual. Gunakan normalisasi dictionary/hashmap dengan deterministic slot key.

// types/inventory.ts
export type SlotPosition = "LEFT_ARM_1" | "LEFT_ARM_2" | "RIGHT_ARM_1" | "RIGHT_ARM_2";

export interface EquipmentSlot {
  id: string;
  position: SlotPosition;
  itemId: string | null;
  itemName: string;
}

export type SlotDictionary = Record<SlotPosition, EquipmentSlot>;

Solusi 2: Pure Functional Mapping dengan Deterministic Keys

Dengan data yang telah dinormalisasi, render setiap slot menggunakan deklarasi target yang eksplisit. Komponen tidak lagi menebak posisi berdasarkan index array.

// components/NormalizedInventory.tsx
import React from "react";
import { SlotPosition, SlotDictionary } from "@/types/inventory";

const LEFT_SLOTS: SlotPosition[] = ["LEFT_ARM_1", "LEFT_ARM_2"];
const RIGHT_SLOTS: SlotPosition[] = ["RIGHT_ARM_1", "RIGHT_ARM_2"];

interface InventoryProps {
  slots: SlotDictionary;
}

function SlotRenderer({ position, data }: { position: SlotPosition; data?: SlotDictionary[SlotPosition] }) {
  return (
    <div 
      key={position} 
      id={`slot-${position.toLowerCase()}`} 
      className="border p-2 rounded"
      data-hydrated="true"
    >
      <span className="font-bold">{position}: </span>
      <span>{data?.itemName ?? "Empty"}</span>
    </div>
  );
}

export function NormalizedInventory({ slots }: InventoryProps) {
  return (
    <div className="grid grid-cols-2 gap-4">
      <div className="flex flex-col gap-2">
        {LEFT_SLOTS.map((pos) => (
          <SlotRenderer key={pos} position={pos} data={slots[pos]} />
        ))}
      </div>
      <div className="flex flex-col gap-2">
        {RIGHT_SLOTS.map((pos) => (
          <SlotRenderer key={pos} position={pos} data={slots[pos]} />
        ))}
      </div>
    </div>
  );
}

Verifikasi Konsistensi Hydration via Assertion

Untuk mencegah regresi tanpa harus bergantung pada manual visual check, jalankan sanity assert invariant pada skema data sebelum diteruskan ke UI layer.

// utils/assertInventoryIntegrity.ts
import { SlotDictionary, SlotPosition } from "@/types/inventory";

const REQUIRED_POSITIONS: SlotPosition[] = [
  "LEFT_ARM_1",
  "LEFT_ARM_2",
  "RIGHT_ARM_1",
  "RIGHT_ARM_2"
];

export function assertInventoryIntegrity(data: unknown): asserts data is SlotDictionary {
  if (!data || typeof data !== "object") {
    throw new Error("[HYDRATION_SANITY_CHECK] Inventory state must be a valid object");
  }

  const dict = data as Record<string, unknown>;
  for (const pos of REQUIRED_POSITIONS) {
    if (!(pos in dict)) {
      throw new Error(`[HYDRATION_SANITY_CHECK] Missing deterministic slot key: ${pos}`);
    }
  }
}

Eksekusi assertion ini pada boundary data fetcher atau root wrapper komponen. Jika server mengirim payload dengan key yang hilang atau corrupt, runtime akan melempar error secara eksplisit sebelum rekonsiliasi DOM menghasilkan UI asimetris yang rusak diam-diam.