Navigate
Search topics across all sections
Code Structuring
Your R3F project started as one file. Then it grew to five. Then twenty. Now nobody knows where the camera setup lives, the player controller is mixed with the UI overlay, and adding a feature means touching six files. Time to organize.
You join a team with an existing R3F project. The main Scene.tsx file is 1,200 lines. State for the HUD, physics, animations, and input handling are all tangled together. You need to change how enemies spawn, but every change to Scene.tsx risks breaking the player controller.
DX Issue: Scene.tsx — 1,200 lines | 47 useState calls | 12 useRef declarations | 8 useFrame callbacks in one component | PR reviews take 2 hours
Real-world
Think of your codebase like a restaurant kitchen.
The head chef (your App component) coordinates everything but does not chop onions. Each station (component) has one job: grill, sauce, plating. Recipes (custom hooks) encode reusable techniques. Ingredients (assets, constants) are stored in labeled containers.
A well-organized kitchen serves food fast. A messy one -- where the pasta water is next to the dessert torch and nobody labeled the containers -- burns everything.
Monolith
One giant Scene.tsx
Split Features
Component per concern
Extract Hooks
Reusable behavior
Separate UI & 3D
Independent trees
Clean Architecture
Easy to extend
Feature-Based Folder Structure
Organize by feature, not by file type. Everything related to the player lives in the player folder. This means you never hunt across folders to understand one feature.
Recommended project layout
src/
app/
page.tsx # Entry point
layout.tsx # Root layout
components/
ui/ # Reusable UI (buttons, modals)
canvas/
Canvas.tsx # Canvas wrapper with gl settings
Scene.tsx # Scene composition (assembles features)
features/
player/
Player.tsx # 3D component
usePlayer.ts # Movement, animation hooks
player.store.ts # Zustand slice for player state
player.constants.ts # Speed, health defaults
environment/
Environment.tsx # Terrain, sky, lighting
useWeather.ts # Dynamic weather hook
enemies/
EnemySpawner.tsx # Spawning logic
Enemy.tsx # Individual enemy
useEnemyAI.ts # AI behavior hook
hooks/
useFrame.ts # Shared frame-loop utilities
useInput.ts # Keyboard/mouse abstraction
stores/
game.store.ts # Global game state (score, level)
assets/
models/ # .glb, .gltf files
textures/ # Images, HDR maps
sounds/ # Audio filesEach feature folder contains its component, hooks, store slice, and constants. When a feature changes, all the relevant code is in one place. When a feature is removed, you delete one folder.
Splitting Scene Components
Your Scene component should read like a table of contents -- listing what exists in the scene, not how each thing works.
Scene as composition root
// Scene.tsx — clean, readable, 30 lines
import { Player } from '@/features/player/Player'
import { Environment } from '@/features/environment/Environment'
import { EnemySpawner } from '@/features/enemies/EnemySpawner'
import { Lighting } from '@/features/environment/Lighting'
export function Scene() {
return (
<>
<Lighting />
<Environment />
<Player />
<EnemySpawner />
</>
)
}Reading this file tells you immediately what the scene contains. Each component owns its internal logic. To understand how enemies work, you open EnemySpawner.tsx. You never need to scroll through hundreds of lines of unrelated code.
Extracting Custom Hooks
Custom hooks are the recipes in your kitchen. They encode reusable behavior that multiple components might need.
A clean custom hook pattern
// usePlayer.ts — all player behavior in one place
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { useInput } from '@/hooks/useInput'
import * as THREE from 'three'
export function usePlayer() {
const ref = useRef<THREE.Group>(null)
const velocity = useRef(new THREE.Vector3())
const { forward, backward, left, right } = useInput()
useFrame((_, delta) => {
if (!ref.current) return
// Movement
const speed = 5
velocity.current.set(0, 0, 0)
if (forward) velocity.current.z -= speed
if (backward) velocity.current.z += speed
if (left) velocity.current.x -= speed
if (right) velocity.current.x += speed
velocity.current.normalize().multiplyScalar(speed * delta)
ref.current.position.add(velocity.current)
})
return { ref }
}
// Player.tsx — tiny, just renders
import { usePlayer } from './usePlayer'
export function Player() {
const { ref } = usePlayer()
return (
<group ref={ref}>
<mesh>
<capsuleGeometry args={[0.3, 1, 8, 16]} />
<meshStandardMaterial color="blue" />
</mesh>
</group>
)
}The hook handles all behavior (input, movement, collision). The component just attaches the ref and renders JSX. This separation means you can unit-test the movement math without rendering anything, and swap the player model without touching the movement code.
State Management with Zustand
R3F applications need state that flows between the 3D scene and the HTML UI without causing unnecessary re-renders. Zustand is the de facto standard for this.
Zustand store with selectors
import { create } from 'zustand'
interface GameState {
score: number
health: number
phase: 'menu' | 'playing' | 'paused' | 'gameover'
addScore: (points: number) => void
takeDamage: (amount: number) => void
setPhase: (phase: GameState['phase']) => void
}
export const useGameStore = create<GameState>((set) => ({
score: 0,
health: 100,
phase: 'menu',
addScore: (points) => set((s) => ({ score: s.score + points })),
takeDamage: (amount) => set((s) => ({
health: Math.max(0, s.health - amount),
phase: s.health - amount <= 0 ? 'gameover' : s.phase,
})),
setPhase: (phase) => set({ phase }),
}))
// In a 3D component — only re-renders when score changes
function ScorePopup() {
const score = useGameStore((s) => s.score)
return <Text3D>{score}</Text3D>
}
// In the HTML UI — only re-renders when health changes
function HealthBar() {
const health = useGameStore((s) => s.health)
return <div style={{ width: `${health}%` }} />
}The selector pattern ensures each component only re-renders when its specific slice of state changes. The 3D ScorePopup does not re-render when health changes, and the HTML HealthBar does not re-render when the score changes. This is critical for keeping the Canvas performant.
What you just learned
Feature-based folders keep all related code (component, hook, store, constants) together.
Scene components should read like a table of contents — list features, not implement them.
Custom hooks encapsulate behavior (movement, animation, AI). Components just render JSX.
Zustand with selectors prevents cross-domain re-renders between UI and 3D.
Separating UI and 3D into independent React trees prevents toggle-a-modal-re-render-the-canvas problems.
Question
You need to share the player's position between the 3D scene (for enemy AI targeting) and the HTML UI (for a minimap). Using useState would re-render the Canvas every frame. Using a ref would not trigger UI updates. What is the right pattern?
Think about it...
You have a Zustand store with score, health, and playerPosition (updated every frame). A HUD component subscribes to score and health. Will the HUD re-render 60 times per second because playerPosition changes every frame?
Hint: Zustand's useStore hook takes a selector function. What does the selector return, and what triggers a re-render?
These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
// Scene.tsx — 800 lines of everything
function Scene() {
const [score, setScore] = useState(0)
const [health, setHealth] = useState(100)
const playerRef = useRef()
// ... 50 more state variables
// ... physics logic
// ... animation logic
// ... UI logic
// ... input handling
return (
<>
{/* 200 lines of JSX */}
</>
)
}function App() {
const [showSettings, setShowSettings] = useState(false)
const [volume, setVolume] = useState(0.5)
return (
<Canvas>
{/* This re-renders when showSettings changes */}
<Scene volume={volume} />
</Canvas>
<SettingsModal
open={showSettings}
onClose={() => setShowSettings(false)}
/>
)
}function Particles() {
useFrame(({ clock }) => {
for (let i = 0; i < 1000; i++) {
const t = clock.elapsedTime
const x = Math.sin(t + i * 0.1) * 2
const y = Math.cos(t + i * 0.15) * 3
const z = Math.sin(t * 0.5 + i * 0.05)
// ... 30 more lines of math
dummy.position.set(x, y, z)
dummy.updateMatrix()
mesh.current.setMatrixAt(i, dummy.matrix)
}
mesh.current.instanceMatrix.needsUpdate = true
})
// ...
}Best Practices
One Component, One Job
If a component does more than one thing (renders a mesh AND handles input AND manages state), split it. The component renders JSX. The hook handles behavior. The store manages state.
Lazy Load Heavy Scenes
Use dynamic imports for scenes that are not immediately visible. React.lazy and Next.js dynamic() let you code-split heavy 3D scenes so they only load when the user navigates to them.
Keep Frame Logic in Hooks
Every useFrame callback should live in a custom hook, not inline in a component. This makes animation and physics logic testable, reusable, and easy to disable by simply not calling the hook.
Type Your Stores
Define TypeScript interfaces for every Zustand store. This catches bugs at compile time -- if a component selects s.socre instead of s.score, TypeScript tells you before your users do.