Hydration error pada React Three Fiber (R3F) terjadi saat renderer React di sisi server menghasilkan representasi DOM yang berbeda dengan struktur DOM aktual yang diinisialisasi oleh browser. Masalah ini umum ditemukan pada arsitektur SSR seperti Next.js atau Vite SSR yang memuat context visual 3D.
Akar Masalah: WebGL Context dan Perbedaan DOM
Komponen <Canvas /> dari R3F membungkus elemen native HTML <canvas> dan container <div> dengan style kalkulasi otomatis (seperti dimensi bounding box dan inline CSS). Di lingkungan Node.js (server), WebGL API (HTMLCanvasElement.getContext('webgl2')) tidak tersedia secara native.
Ketika server merender markup, server membuat struktur HTML statis fallback atau bahkan tree kosong. Begitu JavaScript dieksekusi di browser, R3F langsung mendeteksi ukuran layar (viewport resize listener), menginisialisasi context WebGL, dan menyuntikkan style inline tambahan. React mendeteksi perbedaan atribut dan hierarki node ini, memicu pesan kesalahan Hydration failed because the initial UI does not match what was rendered on the server.
Arsitektur web game 3D modern—seperti implementasi loop render pada proyek open-source winsznx/bull-rush—menghindari inisialisasi context 3D di tahap server-side rendering. Rendering WebGL diisolasi sepenuhnya ke layer client runtime.
Solusi 1: Isolasi Render via Dynamic Import (No SSR)
Langkah primer adalah mencegah React mengeksekusi modul R3F dan komponen Canvas di server. Gunakan lazy loading deterministik.
// components/SceneWrapper.jsx
import dynamic from 'next/dynamic';
// Isolasi total komponen Canvas dari runtime SSR Node.js
const GameCanvas = dynamic(() => import('./GameCanvas'), {
ssr: false,
loading: () => <div className="canvas-placeholder" style={{ width: '100%', height: '100vh', background: '#0a0a0a' }} />
});
export default function SceneWrapper() {
return (
<main style={{ width: '100vw', height: '100vh', position: 'relative' }}>
<GameCanvas />
</main>
);
}Solusi 2: Wrapper HTML Placeholder yang Deterministik
Kesalahan umum adalah merender null saat loading di sisi server, kemudian langsung merender Canvas di sisi client. Pendekatan ini rentan menyebabkan pergeseran layout (layout shift) dan hydration mismatch bila ada parent container yang memiliki styling adaptif.
Pastikan placeholder container memiliki dimensi, class, dan inline-style yang identik antara render server dan render client awal sebelum context Canvas terpasang.
// components/GameCanvas.jsx
'use client';
import { Canvas } from '@react-three/fiber';
import { useEffect, useState } from 'react';
function Box() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
export default function GameCanvas() {
// Memastikan mounting WebGL sinkron dengan lifecycle client
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="canvas-placeholder" style={{ width: '100%', height: '100vh', background: '#0a0a0a' }} />;
}
return (
<Canvas camera={{ position: [0, 0, 5] }}>
<ambientLight intensity={0.5} />
<Box />
</Canvas>
);
}Solusi 3: Sinkronisasi State Game Store (Zustand)
Aplikasi web 3D sering kali menggunakan store eksternal seperti Zustand untuk manajemen state frame-rate, posisi entitas, atau skor game. Mengakses state browser-dependent (seperti window.innerWidth) secara sinkron pada inisialisasi Zustand store akan merusak konsistensi SSR.
Hindari pemanggilan mutasi state store di root level canvas yang dapat memicu re-render cascade saat Canvas di-mount.
// store/useGameStore.js
import { create } from 'zustand';
export const useGameStore = create((set) => ({
isGameReady: false,
score: 0,
// Inisialisasi state murni deterministik
setGameReady: (ready) => set({ isGameReady: ready }),
incrementScore: () => set((state) => ({ score: state.score + 1 })),
}));Konsumsi store tersebut di dalam child component Canvas menggunakan hook terpisah atau lifecycle effect, bukan di dalam render tree terluar:
// components/GameController.jsx
'use client';
import { useEffect } from 'react';
import { useGameStore } from '../store/useGameStore';
export function GameController() {
const setGameReady = useGameStore((state) => state.setGameReady);
useEffect(() => {
// Jalankan mutasi state hanya setelah canvas terpasang penuh pada client
setGameReady(true);
return () => setGameReady(false);
}, [setGameReady]);
return null;
}Checklist Debugging
- Pastikan flag
ssr: falseaktif padanext/dynamicsaat mengimpor komponen Canvas. - Hindari akses langsung global object (
window,document,navigator) di dalam tubuh komponen sebelumuseEffect. - Gunakan CSS container dengan aspek rasio atau dimensi tetap untuk meminimalisir layout recalculation saat WebGL context selesai diinisialisasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!