React Three Fiber入門|ReactでThree.jsを書くと、何が変わるのか
みなさんこんにちは。フロントエンドエンジニアのしゅん(@shun_webdesign)です。
前回の3Dモデル(GLTF)を読み込む方法で、Three.jsの基礎編は一区切りつきました。世界を作って、光を置いて、好きなモデルを並べられるところまで来ています。
ここから先は「何で書くか」の話です。ReactでWebを作っている人なら、一度は名前を見たことがあるはず。React Three Fiber(R3F)です。
今回は、シリーズでずっと使ってきた base.js の世界をまるごとJSXに書き直しながら、R3Fが何をしてくれているのかを見ていきます。新しい3Dの概念はひとつも出てきません。同じものを、別の書き方で書くだけです。
Table of Contents
R3Fは「Three.jsのラッパー」ではない
まずここを勘違いすると、あとが全部ズレます。
R3Fは、Three.jsを使いやすくパッケージしたライブラリ……ではありません。Reactのレンダラーです。
React本体は「UIをどう組み立てるか」だけを知っていて、実際に何を出力するかは知りません。それを担当するのがレンダラーです。
| レンダラー | 出力するもの |
|---|---|
react-dom | DOM要素(<div>・<button>) |
react-native | ネイティブのビュー |
@react-three/fiber | Three.jsのオブジェクト(Mesh・Light・Material) |
つまり、react-dom が <div> を見て document.createElement("div") を呼ぶのとまったく同じ理屈で、R3Fは <mesh> を見て new THREE.Mesh() を呼びます。
ここが大事なところで、R3FにはThree.jsの機能が一切含まれていません。Three.js側に新しいクラスが増えたら、R3Fを更新しなくてもその日から使えます。ラッパーだったらこうはいきません。
そして当然、これまで学んだ知識はそのまま持ち込めます。MeshStandardMaterialのroughnessが何かを知っていることは、R3Fでも100%そのまま役に立ちます。
バージョンの組み合わせだけ最初に確認する
R3Fで最初につまずくのは、たいてい3Dではなくここです。R3FはReactのメジャーバージョンと組で動きます。
| React | React Three Fiber | drei(補助ライブラリ) |
|---|---|---|
| 18 | v8 | v9系 |
| 19 | v9(現行) | v10系 |
Reactを19に上げないままR3F v9を入れると動きません。逆も同じです。既存プロジェクトに後乗せするときは、まずpackage.jsonのReactを見てください。
npm install three @react-three/fiber @react-three/drei
この記事のコードは React 19.2.8 / @react-three/fiber 9.7.0 / @react-three/drei 10.7.8 / three 0.180.0 で実際に動かして確認しています。
v8から上げる場合の主な破壊的変更は、React 19必須になったことに加えて、StrictModeが親から正しく継承されるようになった点です。これまで<Canvas>の外側のStrictModeはCanvas内に効いていなかったので、v9に上げた途端に「初期化が2回走ることを前提にしていなかったコード」が壊れて見えることがあります。バグが増えたわけではなく、隠れていたバグが表に出たというやつです。詳細は公式のv9移行ガイドにまとまっています。
同じ世界を、JSXで書き直す

では本題です。シリーズで使ってきたbase.jsは、こういうものでした。
// これまでの書き方(抜粋)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x15171c);
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(0, 1, 3);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 1.5);
const directional = new THREE.DirectionalLight(0xffffff, 2);
directional.position.set(2, 3, 4);
directional.castShadow = true;
scene.add(ambient, directional);
これがR3Fだと、こうなります。
import { Canvas } from "@react-three/fiber";
export default function App() {
return (
<Canvas shadows camera={{ position: [0, 1, 3], fov: 75 }}>
<color attach="background" args={["#15171c"]} />
<ambientLight intensity={1.5} />
<directionalLight position={[2, 3, 4]} intensity={2} castShadow />
<Cube />
</Canvas>
);
}
Scene・Camera・Rendererを作るコードが消えました。 <Canvas>がその3つを丸ごと引き受けて、さらにcanvas要素の追加とリサイズ対応まで面倒を見ています。base.jsで自分で書いていた土台が、まるごとタグ1つになったかたちです。
base.jsの各行がどこへ行ったのかを並べると、こうなります。
base.jsでやっていたこと | R3Fでの居場所 |
|---|---|
| Scene・Camera・Renderer生成 | <Canvas> が内包 |
renderer.setSize / リサイズ追従 | <Canvas> が自動 |
renderer.shadowMap.enabled | <Canvas shadows> |
scene.add(mesh) | JSXの入れ子構造そのもの |
setAnimationLoop | useFrame フック |
| OrbitControls・環境マップ | drei のコンポーネント(後述) |
scene.add()が消えているのがポイントです。R3FではJSXの親子関係がそのままシーングラフの親子関係になります。追加も削除も、Reactが条件分岐でタグを出し入れすれば勝手にやってくれる。ここがいちばん体感の変わるところです。
<mesh>や<boxGeometry>は、Reactが知っているタグではありません。R3Fが変換しています。ルールは3つだけです。
ルール1|小文字のタグ = THREEのクラス
タグ名の頭を大文字にすると、THREEのクラス名になります。
<mesh /> // → new THREE.Mesh()
<boxGeometry /> // → new THREE.BoxGeometry()
<directionalLight /> // → new THREE.DirectionalLight()
一覧表を暗記する必要はありません。 Three.jsのドキュメントでクラスを見つけたら、頭を小文字にすればそれがタグ名です。
ルール2|args = コンストラクタの引数
newに渡していた引数は、argsに配列で渡します。
<boxGeometry args={[1, 1, 1]} /> // → new THREE.BoxGeometry(1, 1, 1)
<color args={["#15171c"]} /> // → new THREE.Color("#15171c")
ひとつ注意があります。argsが変わるとインスタンスが作り直されます。 コンストラクタ引数は後から変えられないので、R3Fは黙ってnewし直すわけです。
実際に確かめると分かりやすくて、colorを変えたときはmaterialもgeometryも同じインスタンスのまま(uuidが変わらない)色だけが差し替わるのに、argsを[1,1,1]から[2,1,1]に変えた瞬間、geometryは別物に作り直されます。
つまり、毎フレーム変わる値をうっかりargsに入れると、そのたびにインスタンスが作り直されて重くなります。動かしたい値は次のルール3側に置いてください。
ルール3|その他のprops = インスタンスのプロパティ
残りのpropsは、そのままプロパティに代入されます。
<mesh position={[0, 1, 3]} /> // → mesh.position.set(0, 1, 3)
<mesh rotation-x={-Math.PI / 2} /> // → mesh.rotation.x = -Math.PI / 2
<meshStandardMaterial color="#4a90d9" roughness={0.3} metalness={0.6} />
rotation-xのようにハイフンで潜れるのが地味に便利で、これはピアス記法と呼ばれます。material-colorのように、子のさらに奥のプロパティまで一発で触れます。
そしてattach。geometryとmaterialは<mesh>の子に置けば自動で正しい場所に入りますが、それ以外は行き先を明示します。
<color attach="background" args={["#15171c"]} /> // → scene.background = ...
これは「親のbackgroundプロパティに入れてくれ」という指示です。<fog attach="fog">なども同じ形になります。
setAnimationLoopに相当するのがuseFrameです。ここが素のThree.jsとの一番の違いなので、少し丁寧にいきます。
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
function Cube() {
const ref = useRef();
useFrame((state, delta) => {
ref.current.rotation.y += delta * 0.5; // deltaが渡ってくる
});
return (
<mesh ref={ref} castShadow>
<boxGeometry />
<meshStandardMaterial color="#4a90d9" roughness={0.3} metalness={0.6} />
</mesh>
);
}
第2引数のdeltaは前フレームからの経過秒です。A4のAnimationMixerで自分で差分を計算していたやつが、最初から渡ってきます。
そして絶対に押さえてほしい原則がこれです。
useFrameの中でsetStateを呼ばない。refを直接触る。
毎フレームsetStateすると、毎フレームReactの再レンダリングが走ります。60fpsで再レンダリング60回。React側が確実に音を上げます。
R3Fでは、Reactの再レンダリングと描画ループは完全に別物です。Reactは「シーンに何を置くか」の担当で、「毎フレーム値をどう動かすか」はref経由でThree.jsのオブジェクトを直接殴る。この住み分けができれば、R3Fは素のThree.jsと同じ速度で動きます。
初めて触ったときの僕は、ここを分かっておらず「Reactで書くと重い」と思い込んでいました。重かったのは書き方のほうでした。
dreiを入れると、書く量が激減する
@react-three/dreiは、R3Fのよく使う部品を詰め合わせた公式のヘルパー集です。base.jsの残りの部分は、ほぼこれで片付きます。
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Environment } from "@react-three/drei";
<Canvas shadows camera={{ position: [0, 1, 3], fov: 75 }}>
<color attach="background" args={["#15171c"]} />
<ambientLight intensity={1.5} />
<directionalLight position={[2, 3, 4]} intensity={2} castShadow />
<Environment preset="apartment" /> {/* 環境マップ */}
<Cube />
{/* 影を受ける床 */}
<mesh rotation-x={-Math.PI / 2} position-y={-1} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#2a2d34" roughness={1} />
</mesh>
<OrbitControls enableDamping />
</Canvas>
PMREMGeneratorもOrbitControlsの初期化も消えました。これでbase.jsのcreateWorld()は完全に置き換わったことになります。
⚠️
<Environment preset="...">は、プリセットのHDRI画像をCDNから取得します。オフラインで動かしたい・外部依存を切りたい場合はfilesにローカルのHDRIを指定してください。
GLTFの読み込みも、dreiだと1行です。
import { Suspense } from "react";
import { useGLTF } from "@react-three/drei";
function Model() {
const { scene } = useGLTF("/robot.glb");
return <primitive object={scene} />;
}
// 使う側は Suspense で包む
<Suspense fallback={null}>
<Model />
</Suspense>
<primitive>は「既にあるThree.jsオブジェクトをそのままシーンに置く」ためのタグで、ローダーで作ったものを扱うときの定番です。A4で書いたloader.load()のコールバックが、まるごと消えたのが分かると思います。
<Suspense>は「なくても動く」が、無いと全部消える
useGLTFは読み込みが終わるまでサスペンドします。ここで多くの入門記事が「<Suspense>で囲むのは必須」と書いていますが、実際には囲まなくてもクラッシュしません。<Canvas>が内部にSuspenseを持っているからです。
ただし、その内部fallbackの中身がなかなか強烈で、Canvasコンポーネント自身をサスペンドさせる作りになっています。つまり内側で誰かが読み込み待ちに入ると、その間シーンごと消えます。

手元で試したのがこれです。同じシーン(回る箱+読み込み中のモデル)で、<Suspense>の有無だけを変えました。
| 読み込み中に見えるもの | |
|---|---|
<Suspense>で包まない | 真っ白。箱すら出ない(シーン全体が待たされる) |
<Suspense fallback={null}>で包む | 箱は出たまま。モデル部分だけが待つ |
「モデルを足したら画面が真っ白になった」の正体はだいたいこれです。落ちているのではなく、シーン全体が読み込みを待っている。
なので、正しい理解はこうなります。
<Suspense>は必須ではない。だが「どこまでを待たせるか」の境界線なので、置かないと境界がCanvas全体になる。
読み込みに時間がかかるものほど、狭く囲む。これだけ覚えておけば大丈夫です。
で、R3Fにすべきなのか?
正直に書きます。全部R3Fにしたほうがいい、とは思っていません。
| R3Fが効くとき | 素のThree.jsで十分なとき |
|---|---|
| すでにReact / Next.jsで作っている | 静的サイトの単発演出だけ3Dにしたい |
| UIの状態と3Dが連動する(ボタンで色を変える等) | 描画がずっと自走していればいい |
| 3Dのパーツを再利用・量産したい | 1シーンで完結する作品 |
| dreiのエコシステムに乗りたい | シェーダー中心の実験 |
判断軸はシンプルで、「そのページにReactがもういるか」です。いるなら乗せたほうが圧倒的に楽。いないなら、Reactを連れてくるコストのほうが大きいことがほとんどです。
僕自身、Next.jsでポートフォリオを作ったときはR3Fを選びましたが、Xに上げている単発の作品は今も素のThree.jsで書いています。道具は使い分けるものです。
よくあるつまずき
| 症状 | 原因 | 対処 |
|---|---|---|
Hooks can only be used within the Canvas component! | useFrame・useThreeを<Canvas>の外で呼んでいる | 中身をコンポーネントに切り出して<Canvas>の子にする |
| 何も起きない(エラーすら出ない) | 親要素に高さがなく、<Canvas>のサイズが0 | 親にheightを与える(100vhなど)。サイズ0だとWebGLの初期化自体が走らない |
| 影が落ちない | shadowsを付け忘れ | <Canvas shadows> + castShadow / receiveShadow |
| モデルを足したら画面が真っ白 | 読み込み待ちをCanvas全体が受けている | useGLTFを使う側を<Suspense>で狭く包む |
Next.jsでwindow is not defined | SSR時にThree.jsが動く | dynamic(() => import(...), { ssr: false }) |
| 動きがカクつく・重い | useFrame内でsetState | refを直接更新する |
| そもそもビルドが通らない | React / R3F / drei のバージョン不一致 | 上のバージョン表を確認 |
まとめ
- R3FはReactのレンダラー。
react-domがDOMを吐くように、Three.jsのオブジェクトを吐く。Three.jsの知識はそのまま使える - React 19 → R3F v9 → drei v10 の組で使う。ここのズレが初手の事故原因
- 変換ルールは3つ。小文字タグ=クラス /
args=コンストラクタ引数 / その他props=プロパティ(rotation-xのピアス記法つき) scene.add()は書かない。JSXの入れ子がそのままシーングラフ- ループは
useFrame。中でsetStateせずrefを直接触る——これだけ守れば速度は落ちない - dreiの
<OrbitControls>・<Environment>・useGLTFで、土台のコードはほぼ消える <Suspense>は必須ではないが、「どこまでを待たせるか」の境界線。置かないと境界がシーン全体になる(=読み込み中に真っ白)
シリーズでずっと育ててきた「回る箱」の世界が、そのままJSXに引っ越せました。中身の3Dは何ひとつ変わっていない——これがR3Fの一番の安心材料だと思います。
- 次は、マウス追従やスクロール連動で箱を触れるようにする回(近日公開)
- 実際にR3Fでサイトを1本作った記録は → Next.jsとReact Three Fiberのポートフォリオを公開するまでの裏側
- 描画エンジンごとWebGPUに載せ替える話は → WebGPURendererへの移行ガイド
- 全体のロードマップは → Three.js入門ガイド
📕 もっと手を動かして体系的に学びたい人へ
この記事のような「仕組みを理解しながら小さく動かす」を積み重ねて、作品づくりまで導くKindle本を書きました。
『Three.jsでつくる、小さなWebGL表現 ── はじめての”作品づくり”ガイド』
環境づくりから、モデルや質感を組み合わせて自分の作品にまとめ上げるところまで順を追って解説しています。
僕もXでThree.jsの作品や知見を発信しているので、よかったら@shun_webdesignを覗いてもらえると嬉しいです。それでは、よいThree.jsライフを🌊