Three.js + React: інтеграція та стейт-менеджмент
React хоче володіти DOM через декларативне віртуальне дерево.
Three.js хоче прямого, імперативного контролю над графом сцени
WebGL, що працює 60 разів на секунду. Поєднайте їх наївним
способом — useEffect, що будує рендерер, і
useState, що керує кожним кадром анімації — і ви
отримаєте або витік пам'яті, або компонент, що ре-рендерить сам
себе до колапсу. react-three-fiber правильно
узгоджує ці дві моделі, і цей туторіал розкриває те, що дійсно
важливо: цикл рендеру, refs, і де має жити стан симуляції.
1. Чому не просто useEffect + чистий Three.js
Спокусливо трактувати Three.js як «шлях відступу»: змонтувати
ref на <canvas>, побудувати
рендерер/сцену/камеру всередині одного useEffect, і
запустити власний цикл requestAnimationFrame повністю
поза React. Це працює для окремого статичного демо, але відкидає
все, у чому React хороший:
- Немає декларативної композиції — додавання/ видалення об'єктів означає ручне відстеження того, що вже є в сцені, і самостійне порівняння (diff).
-
Немає умовного рендерингу — показ/приховування
меша залежно від пропсів означає написання власного обліку
scene.add/scene.removeзамість простого{ умова && <Mesh /> }. - Застарілі замикання — цикл рендеру захоплює ті пропси/стан, що існували на момент запуску ефекту; синхронізація зі швидко змінним станом React все одно потребує ручних refs.
react-three-fiber (R3F) — це рендерер React для
Three.js — не бібліотека-обгортка, а справжній кастомний
reconciler, як React DOM. JSX-елементи мапляться 1:1 на класи
Three.js (<mesh> → THREE.Mesh,
<boxGeometry> →
THREE.BoxGeometry), тож ви отримуєте справжню
композицію компонентів, контекст, suspense і хуки — тоді як R3F
обробляє мутації графа сцени «під капотом».
2. Налаштування react-three-fiber
npm install three @react-three/fiber @react-three/drei
<Canvas> створює рендерер, сцену, камеру за
замовчуванням і запускає цикл рендеру. Все всередині — це
Three.js у JSX:
// App.jsx
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
export default function App() {
return (
<Canvas camera={{ position: [0, 2, 6], fov: 50 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[3, 5, 2]} intensity={1} />
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="#60a5fa" />
</mesh>
<OrbitControls />
</Canvas>
);
}
Теги JSX у нижньому регістрі (<mesh>,
<sphereGeometry>) — конвенція R3F для
«будь-якого класу Three.js, визначеного за назвою»:
mesh → new THREE.Mesh(),
sphereGeometry → new THREE.SphereGeometry().
Пропси мапляться на аргументи конструктора (args)
або пряме присвоєння властивості (color,
position, intensity).
3. Анімація через useFrame, а не setState
Найважливіше правило в R3F: ніколи не керуйте
покадровою анімацією через useState.
Виклик setState 60 разів на секунду змушує React
ре-рендерити дерево компонентів 60 разів на секунду — саме ті
накладні витрати, яких React мав би допомагати уникати.
// ❌ Так не робіть — ре-рендерить React щокадру
function SpinningBox() {
const [rotation, setRotation] = useState(0);
useEffect(() => {
let raf;
const tick = () => {
setRotation((r) => r + 0.01); // ініціює ре-рендер React
raf = requestAnimationFrame(tick);
};
tick();
return () => cancelAnimationFrame(raf);
}, []);
return <mesh rotation-y={rotation}>...</mesh>;
}
// ✅ Так — мутує об'єкт Three.js напряму, без рендеру React
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function SpinningBox() {
const meshRef = useRef(null);
useFrame((state, delta) => {
meshRef.current.rotation.y += delta; // пряма мутація, майже безкоштовно
});
return (
<mesh ref={meshRef}>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
);
}
useFrame реєструє callback у внутрішньому циклі
рендеру R3F (один спільний requestAnimationFrame
для всього дерева, а не по одному на компонент). Він отримує
delta (секунди з моменту останнього кадру), тож рух
не залежить від частоти кадрів, і спільний об'єкт
state (clock, camera, scene, gl, pointer, viewport).
useFrame у дереві за замовчуванням виконуються у
порядку реєстрації. Передайте числовий пріоритет другим
аргументом (useFrame(cb, 1)), щоб примусово
виконати callback після інших — корисно, коли вихід одного
компонента живить вхід іншого в межах того самого кадру.
4. Refs для імперативного доступу
useRef — спосіб дістатися за декларативний JSX R3F
до базового екземпляра Three.js напряму — необхідно для всього,
що мутує useFrame, а також для рейкастингу,
ручного оновлення геометрії чи виклику методів без відповідного
пропса.
function Particles({ count }) {
const instancedRef = useRef(null);
const dummy = useMemo(() => new THREE.Object3D(), []);
useFrame(() => {
const mesh = instancedRef.current;
if (!mesh) return;
for (let i = 0; i < count; i++) {
dummy.position.set(
Math.sin(i + performance.now() * 0.0005) * 3,
0,
Math.cos(i + performance.now() * 0.0005) * 3
);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={instancedRef} args={[undefined, undefined, count]}>
<sphereGeometry args={[0.05, 8, 8]} />
<meshStandardMaterial color="#60a5fa" />
</instancedMesh>
);
}
Зверніть увагу на проп args у
<instancedMesh>: коли конструктор Three.js
приймає позиційні аргументи, args — це спосіб R3F
їх передати. Зміна масиву args змушує R3F звільнити
старий екземпляр і створити новий — корисно знати, коли проп
несподівано «скидає» ваш об'єкт.
5. Спільний стан симуляції через Zustand
Коли кільком компонентам потрібно читати або писати ті самі
швидко змінні значення (кількість частинок, швидкість симуляції,
обраний об'єкт), useState, піднятий до спільного
батька, все одно ініціює ре-рендер React на кожне оновлення —
та сама проблема, що й у розділі 3, лише розподілена по
компонентах. Zustand зберігає стан повністю
поза React; компоненти підписуються лише на конкретний зріз,
який рендерять, а callback'и useFrame можуть
читати/писати сховище напряму без підписки (і, відповідно, без
жодного ре-рендеру React).
// store.js
import { create } from 'zustand';
export const useSimStore = create((set, get) => ({
speed: 1,
particleCount: 2000,
setSpeed: (speed) => set({ speed }),
}));
// Всередині useFrame — читаємо ОСТАННЄ значення без підписки/ре-рендеру
useFrame((_, delta) => {
const speed = useSimStore.getState().speed; // .getState(), а не хук
meshRef.current.rotation.y += delta * speed;
});
// Всередині UI-компонента — ЦЕ підписує, ре-рендерить при зміні (нормально, це мітка слайдера)
function SpeedSlider() {
const speed = useSimStore((s) => s.speed);
const setSpeed = useSimStore((s) => s.setSpeed);
return (
<input type="range" min="0" max="3" step="0.1"
value={speed} onChange={(e) => setSpeed(+e.target.value)} />
);
}
useSimStore((s) => s.speed)) підписує компонент і
ре-рендерить його при зміні — правильно для UI. Виклик
useSimStore.getState() всередині useFrame
читає поточне значення без підписки й без ре-рендеру — правильно
для циклу анімації. Плутанина між ними в будь-якому напрямку —
найпоширеніший баг продуктивності в R3F.
6. Стан React — для UI, не для сцени
Корисна ментальна модель: проведіть межу між «станом сцени»
(позиція, обертання, покадрова фізика — змінюється щокадру,
читається useFrame) і «станом UI» (чи відкрита
панель налаштувань, який пресет обрано, дебаунсене значення
слайдера — змінюється зрідка, потребує ре-рендеру JSX).
| Тип стану | Де живе | Як читається |
|---|---|---|
| Покадрова трансформація/фізика | Refs, мутуються в useFrame |
Пряма мутація властивості |
| Спільні параметри симуляції між компонентами | Zustand (або Valtio/Jotai) | getState() у циклі, хук у UI |
| Перемикачі/меню/мітки лише для UI | useState/пропси |
Звичайний ре-рендер React |
Цілком нормально — навіть добре — щоб useState
ре-рендерив дерево, коли користувач відкриває панель або обирає
пункт випадаючого списку. Правило вужче за «уникайте стану
React»: точніше — «не дозволяйте ре-рендерам React блокувати
щось, що має відбуватися 60 разів на секунду».
7. Очищення та звільнення ресурсів
R3F автоматично звільняє геометрії та матеріали, створені через
JSX, коли компонент демонтується — одна з найбільших практичних
переваг над рукописним Three.js. Ресурси, створені вручну
(завантажені текстури, власні буфери, побудовані в
useMemo), все одно потребують явного звільнення:
function TexturedPlane({ url }) {
const texture = useMemo(() => new THREE.TextureLoader().load(url), [url]);
useEffect(() => {
return () => texture.dispose(); // виконується при демонтажі або зміні `url`
}, [texture]);
return (
<mesh>
<planeGeometry args={[4, 4]} />
<meshBasicMaterial map={texture} />
</mesh>
);
}
dispose={null} на JSX-елементі — корисно,
коли ви ділите один екземпляр геометрії/матеріалу між багатьма
мешами і не хочете, щоб R3F знищив його, коли демонтується лише
один з них.
8. Патерни продуктивності без змін
Кожна техніка оптимізації з чистого Three.js переноситься в R3F без змін — це той самий рендерер під капотом:
-
<instancedMesh>для великої кількості повторюваних об'єктів (див. приклад з refs вище) — один draw call замість тисяч. -
frameloop="demand"на<Canvas>— повністю зупиняє цикл рендеру, коли нічого не змінилося (статичні діаграми, зупинені симуляції), аinvalidate()запитує один ре-рендер за потреби. -
<Suspense>+ завантажувачі dreiuseGLTF/useTexture— асети завантажуються асинхронно, і сцена монтується лише після їх готовності, з fallback тим часом. -
React.memoна листових компонентах зі стабільними пропсами — ті самі правила, що й для будь-якого React-дерева, оскільки компоненти R3F — все ще звичайні React-компоненти.
Часті запитання
Чого я навчуся в цьому уроці?
Правильно поєднайте Three.js з React-застосунком: декларативний граф сцени react-three-fiber, цикл рендеру useFrame, refs для імперативних об'єктів, Zustand для стану симуляції поза циклом рендеру React, і очищення при демонтажі.
Які теми розглядаються в цьому уроці?
Цей урок охоплює такі теми: Чому не просто useEffect + чистий Three.js, Налаштування react-three-fiber, Анімація через useFrame, а не setState, Refs для імперативного доступу, Спільний стан симуляції через Zustand, Стан React — для UI, не для сцени, Очищення та звільнення ресурсів, Патерни продуктивності без змін.
Скільки часу займає цей урок?
Цей урок займає приблизно 30 хв.
Які попередні знання потрібні?
Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.