Navigate

Search topics across all sections

GitHub
Production

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.

terminal
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

project-structureJS
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 files

Each 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.tsxTSX
// 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.tsTS
// 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

gameStore.tsTS
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.

1
Putting all scene logic in one giant component
The 800-line Scene.tsx that nobody wants to touch
Don't do this
SceneSplitting.tsxTSX
// 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 */}
    </>
  )
}
A single file with hundreds of lines becomes impossible to debug and review. Split by feature: each component owns its own state, refs, and frame logic. When a bug appears in player movement, you open Player.tsx -- not scroll through 800 lines of Scene.tsx hunting for the relevant useRef.
2
Mixing UI state with 3D scene state
Re-renders from a modal toggle cause the entire scene to re-render
Don't do this
UIStateSeparation.tsxTSX
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)}
    />
  )
}
UI state (modals, dropdowns, tooltips) and 3D state (positions, animations, physics) live in different worlds. When they share the same component, toggling a dropdown re-renders the entire Canvas. Use a state manager like Zustand so the 3D scene only re-renders when 3D-relevant state changes.
3
Inlining complex logic in JSX
useFrame callbacks with 40 lines of math buried in the component
Don't do this
ExtractHooks.tsxTSX
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
  })
  // ...
}
Custom hooks encapsulate behavior (animation, physics, input). Pure functions handle math. The component just wires them together and returns JSX. This pattern makes each piece testable in isolation and reusable across different components. When the particle math changes, you edit one function -- not dig through JSX.

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.