Akar Masalah: State Desync pada Rehidrasi SSR

Komponen antarmuka digital fatigue (seperti pelacak batas screen time, modal istirahat, dan banner limitasi feed) membutuhkan pembacaan state persisten lokal. Kesalahan umum terjadi ketika state tersebut dievaluasi langsung pada fase inisialisasi komponen.

Server Side Rendering (SSR) mengeksekusi kode di server tanpa akses ke objek lingkungan browser seperti window, localStorage, atau matchMedia. Jika komponen membaca langsung nilai-nilai ini saat fase render awal:

// Anti-pattern: Menyebabkan hydration mismatch
export function BadFeedLimiter() {
  const isLimitReached = typeof window !== 'undefined' 
    && Number(localStorage.getItem('screen_time_seconds')) > 3600;

  return isLimitReached ? <FeedBlockedBanner /> : <FeedList />;
}

Server merender <FeedList /> karena kondisi typeof window !== 'undefined' bernilai false. Saat HTML tiba di peramban, engine React mengeksekusi render pohon virtual DOM awal klien. Klien menemukan bahwa isLimitReached bernilai true, menghasilkan <FeedBlockedBanner />. Ketidakcocokan struktur tag antara DOM server dan klien memicu React Hydration Error (React error code #418 / #423).

Masalah serupa muncul dari penggunaan Date.now(). Nilai milidetik server saat HTML digenerasi tidak akan pernah identik dengan milidetik klien saat rehidrasi berjalan.

Kelemahan Penanganan Konvensional

Dua pendekatan umum yang kerap diambil namun tidak optimal:

  • suppressHydrationWarning: Atribut ini hanya menonaktifkan pesan error pada konsol runtime. DOM mismatch tetap terjadi, pohon komponen terpaksa di-patch secara destruktif oleh React, dan integritas state aplikasi terancam.
  • Double-pass rendering via useEffect: Menunda pemuatan state browser ke dalam useEffect memaksa komponen merender state kosong terlebih dahulu, lalu memicu re-render instan. Ini menyebabkan Cumulative Layout Shift (CLS) dan visual flickering saat feed tiba-tiba berganti menjadi banner pemblokir.

Solusi Deterministik: useSyncExternalStore

Solusi yang benar secara arsitektural adalah memisahkan snapshot pembacaan data antara lingkungan server dan klien secara deterministik menggunakan API React useSyncExternalStore.

Dengan API ini, server selalu menerima snapshot fallback yang konsisten. Klien memulai hydration dengan snapshot server yang sama, lalu secara sinkron menyelaraskan data dengan storage browser tanpa menimbulkan rekonsiliasi yang rusak.

1. Implementasi Store Eksternal

// feedLimiterStore.ts
type Listener = () => void;

let listeners: Listener[] = [];
const STORAGE_KEY = 'screen_time_limit_reached';

export const feedLimiterStore = {
  subscribe(listener: Listener) {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  },
  getSnapshot(): boolean {
    if (typeof window === 'undefined') return false;
    return localStorage.getItem(STORAGE_KEY) === 'true';
  },
  getServerSnapshot(): boolean {
    // ponytail: fallback deterministik statis. Tambah cookie reading jika butuh identifikasi limit di sisi server edge.
    return false;
  },
  setLimitReached(status: boolean) {
    if (typeof window !== 'undefined') {
      localStorage.setItem(STORAGE_KEY, String(status));
      listeners.forEach((l) => l());
    }
  }
};

Store diekstrak → skipped: sinkronisasi lintas-tab lewat BroadcastChannel, add when multi-window tracking diwajibkan.

2. Konsumsi Store pada Komponen Feed Limiter

// FeedLimiter.tsx
'use client';

import { useSyncExternalStore } from 'react';
import { feedLimiterStore } from './feedLimiterStore';

export function FeedLimiter({ children }: { children: React.ReactNode }) {
  const isLimitReached = useSyncExternalStore(
    feedLimiterStore.subscribe,
    feedLimiterStore.getSnapshot,
    feedLimiterStore.getServerSnapshot
  );

  return (
    <div className="feed-container">
      {isLimitReached ? (
        <div role="alert" className="limiter-banner">
          <h3>Batas Waktu Layar Tercapai</h3>
          <p>Feed dihentikan untuk menjaga fokus Anda.</p>
        </div>
      ) : (
        children
      )}
    </div>
  );
}

Mitigasi Layout Shift Menggunakan CSS Containment

Mencegah CLS saat transisi dari snapshot server ke status pemblokir dilakukan menggunakan isolasi layout via CSS containment dan reservasi ruang dimensi minimum:

.feed-container {
  contain: layout style paint;
  contain-intrinsic-size: auto 400px;
  min-height: 200px;
}

.limiter-banner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 200px;
  padding: 24px;
  background-color: #f8fafc;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

CSS contain: layout style paint menginstruksikan peramban bahwa subtree tersebut independen dari pohon DOM luar, menahan reflow global ketika komponen bersalin rupa.

Pengujian Verifikasi Deterministik

Uji invariant logika snapshot server dan klien tanpa dependensi framework UI melalui berkas pengetesan mandiri:

// test-feed-store.js
const assert = require('assert');

const fakeStorage = new Map();
const store = {
  getSnapshot: (isClient) => (isClient ? fakeStorage.get('limit') === 'true' : false),
  getServerSnapshot: () => false
};

// 1. Uji invarian server: selalu deterministik false
assert.strictEqual(
  store.getServerSnapshot(),
  false,
  'Server snapshot harus selalu mengembalikan false untuk cegah desync'
);

// 2. Uji state awal klien sebelum storage diisi
assert.strictEqual(
  store.getSnapshot(true),
  false,
  'Klien snapshot awal harus konsisten dengan server jika storage kosong'
);

// 3. Uji mutasi state pada klien
fakeStorage.set('limit', 'true');
assert.strictEqual(
  store.getSnapshot(true),
  true,
  'Klien snapshot harus mengembalikan true setelah mutasi storage'
);

console.log('Semua pengecekan logika isolasi snapshot berhasil.');