Туторіал · React · Three.js · Стан
📅 Липень 2026 ⏱ ≈ 30 хв 🎯 Середній рівень

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 хороший:

react-three-fiber (R3F) — це рендерер React для Three.js — не бібліотека-обгортка, а справжній кастомний reconciler, як React DOM. JSX-елементи мапляться 1:1 на класи Three.js (<mesh>THREE.Mesh, <boxGeometry>THREE.BoxGeometry), тож ви отримуєте справжню композицію компонентів, контекст, suspense і хуки — тоді як R3F обробляє мутації графа сцени «під капотом».

Коли чистий Three.js все ще правильний вибір: окрема самодостатня сторінка WebGL без іншого React-UI навколо неї (більшість симуляцій на цьому сайті) не потребує накладних витрат reconciler'а R3F. Використовуйте R3F, коли 3D-сцена живе всередині більшого React-застосунку з маршрутизацією, формами та спільним станом UI.

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, визначеного за назвою»: meshnew THREE.Mesh(), sphereGeometrynew 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).

Порядок має значення: декілька callback'ів 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 без змін — це той самий рендерер під капотом:

Далі: коли межі стану вище стануть звичними, поєднайте це з туторіалом з 3D-оптимізації для технік LOD і відсікання, або з туторіалом TypeScript для Three.js, щоб типізувати refs і сховище з цього прикладу.

Часті запитання

Чого я навчуся в цьому уроці?

Правильно поєднайте Three.js з React-застосунком: декларативний граф сцени react-three-fiber, цикл рендеру useFrame, refs для імперативних об'єктів, Zustand для стану симуляції поза циклом рендеру React, і очищення при демонтажі.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Чому не просто useEffect + чистий Three.js, Налаштування react-three-fiber, Анімація через useFrame, а не setState, Refs для імперативного доступу, Спільний стан симуляції через Zustand, Стан React — для UI, не для сцени, Очищення та звільнення ресурсів, Патерни продуктивності без змін.

Скільки часу займає цей урок?

Цей урок займає приблизно 30 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.