Hydration mismatch pada alur pembatalan langganan (click-to-cancel flow) sering terjadi saat server dan browser menghasilkan representasi DOM yang berbeda dari status langganan pengguna. Masalah ini biasanya berakar pada evaluasi cookie atau session token yang tidak sinkron antara fase SSR dan hidrasi klien. Dampaknya fatal: tombol konfirmasi pembatalan dapat terkunci dalam status non-interaktif atau false disabled state, melanggar regulasi kepatuhan konsumen (seperti FTC click-to-cancel rule) dan merusak pengalaman pengguna.
Root Cause: Desinkronisasi State Sesi SSR dan DOM Tree
Server-side rendering (SSR) pada Next.js merender tree React menjadi HTML statis menggunakan snapshot state saat request diterima. Ketika halaman dihidrasi di browser, runtime React mencocokkan virtual DOM hasil render klien dengan markup HTML dari server. Jika evaluasi token sesi, feature flag, atau pembacaan storage lokal menghasilkan nilai berbeda, React mendeteksi ketidaksesuaian struktur atau atribut DOM.
Perhatikan contoh implementasi bermasalah berikut yang mengevaluasi context auth secara langsung pada saat render awal:
// components/CancelSubscriptionButton.tsx
'use client';
import { useAuth } from '@/hooks/useAuth';
export function CancelSubscriptionButton() {
const { user, isLoaded } = useAuth();
// BUG: Di server, isLoaded = false. Di client, isLoaded langsung true dari cache.
// Menyebabkan atribut disabled berbeda antara SSR dan Client render.
const isEligible = isLoaded && user?.tier === 'pro';
return (
<button
disabled={!isEligible}
className={isEligible ? "bg-red-600 text-white" : "bg-gray-300 text-gray-500"}
>
Batalkan Langganan
</button>
);
}Perbedaan atribut disabled atau className menghasilkan peringatan hidrasi klasik:
Warning: Prop `disabled` did not match. Server: "true" Client: "false"Bahaya False Disabled State
Ketika React 18+ menemukan hydration mismatch pada atribut interaktif, React tidak selalu memperbarui DOM secara mulus jika ada perbedaan tree hierarkis. Node DOM sering kali mempertahankan atribut dari server (misal: disabled="") meskipun event listener klien terpasang. Pengguna mengklik tombol konfirmasi batal, namun tidak ada aksi yang terpicu. Masalah ini menyebabkan gesekan bagi pengguna yang berniat menghentikan langganan dan meningkatkan beban tiket bantuan operasional.
Pola Deterministic State Machine untuk Multi-Step Flow
Alur pembatalan multi-step memerlukan transisi state yang dapat diprediksi: SURVEY, RETENTION_OFFER, CONFIRMATION, dan CANCELED. Menggunakan state machine lokal yang deterministik dan digabungkan dengan sinkronisasi hidrasi menjamin browser tidak merender state transisi sebelum hidrasi selesai.
// hooks/useCancelStateMachine.ts
'use client';
import { useState, useTransition } from 'react';
export type CancelStep = 'SURVEY' | 'OFFER' | 'CONFIRM' | 'PROCESSING' | 'COMPLETED';
export function useCancelStateMachine(initialStep: CancelStep = 'SURVEY') {
const [step, setStep] = useState<CancelStep>(initialStep);
const [isPending, startTransition] = useTransition();
const transitionTo = (nextStep: CancelStep) => {
startTransition(() => {
setStep(nextStep);
});
};
return { step, transitionTo, isPending };
}Resolusi: Dynamic Import Tanpa SSR vs. Flag Mounted
Ada dua pendekatan teruji untuk mengeliminasi risiko mismatch pada alur multi-step:
- Lazy Loading Client-Only: Menggunakan
dynamicimport dengan opsissr: falsepada komponen form interaktif. Pendekatan ini adalah yang paling bersih jika komponen tidak membutuhkan pengindeksan search engine. - Mounted Execution Flag: Memastikan render pertama di klien sama persis dengan snapshot SSR, lalu memperbarui UI setelah mount terjadi.
Solusi 1: Isolasi Komponen dengan next/dynamic (Rekomendasi)
Alternatif paling minim bug adalah membungkus seluruh modal atau alur multi-step pembatalan ke dalam komponen klien murni:
// app/account/billing/page.tsx
import dynamic from 'next/dynamic';
const CancelFlowWizard = dynamic(
() => import('@/components/CancelFlowWizard'),
{
ssr: false,
loading: () => <div className="h-32 animate-pulse bg-gray-100 rounded" />
}
);
export default function BillingPage() {
return (
<section className="max-w-2xl mx-auto py-8">
<h2 className="text-xl font-bold mb-4">Kelola Langganan</h2>
<CancelFlowWizard />
</section>
);
}Solusi 2: Pattern Sinkronisasi Server-Safe State
Jika komponen harus dirender di server untuk alasan layout stability, gunakan sanitasi state melalui custom hook yang mendeteksi mount secara deterministik:
// hooks/useHydrated.ts
'use client';
import { useSyncExternalStore } from 'react';
const emptySubscribe = () => () => {};
export function useHydrated(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true, // Snapshot browser
() => false // Snapshot server
);
}Implementasi komponen tombol konfirmasi pembatalan menggunakan useHydrated:
// components/StepConfirmCancel.tsx
'use client';
import { useHydrated } from '@/hooks/useHydrated';
interface StepConfirmProps {
userTier: string;
onConfirm: () => Promise<void>;
isProcessing: boolean;
}
export function StepConfirmCancel({ userTier, onConfirm, isProcessing }: StepConfirmProps) {
const isHydrated = useHydrated();
// Sebelum hidrasi selesai, tampilkan fallback non-interaktif yang stabil
if (!isHydrated) {
return (
<button disabled className="opacity-50 bg-gray-400 text-white cursor-not-allowed px-4 py-2 rounded">
Memuat Status Akun...
</button>
);
}
const canCancel = userTier === 'pro';
return (
<button
type="button"
disabled={!canCancel || isProcessing}
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 disabled:bg-gray-400 text-white font-medium px-4 py-2 rounded transition-colors"
>
{isProcessing ? 'Memproses Pembatalan...' : 'Konfirmasi Batal Langganan'}
</button>
);
}Optimistic UI pada Transisi Step Pembatalan
Saat pengguna menekan tombol konfirmasi pada tahap akhir, jangan menunggu round-trip jaringan selesai untuk menampilkan feedback. Manfaatkan React useOptimistic untuk mencegah penekanan tombol ganda (double-click abuse) yang bisa memicu pembatalan ganda di payment gateway (seperti Stripe atau Midtrans):
// components/CancelFlowWizard.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { cancelSubscriptionAction } from '@/actions/subscription';
export default function CancelFlowWizard({ subscriptionId }: { subscriptionId: string }) {
const [isPending, startTransition] = useTransition();
const [optimisticStatus, setOptimisticStatus] = useOptimistic(
'ACTIVE',
(_, newStatus: string) => newStatus
);
const handleCancel = async () => {
startTransition(async () => {
setOptimisticStatus('CANCELED');
const result = await cancelSubscriptionAction(subscriptionId);
if (!result.success) {
// Rollback otomatis terjadi jika action melempar error atau selesai
console.error(result.error);
}
});
};
if (optimisticStatus === 'CANCELED') {
return <p className="text-green-600">Langganan Anda telah berhasil dibatalkan.</p>;
}
return (
<button
onClick={handleCancel}
disabled={isPending}
className="bg-red-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? 'Membatalkan...' : 'Batalkan Langganan'}
</button>
);
}Checklist Debugging Hydration Mismatch
- Periksa Console Error Browser: Pastikan tidak ada pesan
Text content did not matchyang mengindikasikan perbedaan parsing tanggal kedaluwarsa langganan antara zona waktu server (UTC) dan lokal klien. - Gunakan Server Component untuk Membaca Sesi: Ambil data hak langganan di Server Component via
cookies()atauheaders(), lalu teruskan data tersebut ke Client Component sebagai props deterministik. Hindari pembacaan cookie langsung dari hook klien pada first-render. - Validasi State Mesin: Pastikan step awal alur pembatalan di-hardcode ke step intro (misal:
SURVEY) alih-alih mencoba memulihkannya langsung darilocalStoragesaat inisialisasi state sinkron.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!