Conventional wisdom says large components are bad. Split them up. Single responsibility principle. Keep files under 200 lines. We tried that with App.tsx—and failed. Here's why we kept it at 9,313 lines and what we did instead.
The Component That Couldn't Be Split
EF-Map's App.tsx is the coordinator for our Three.js-based star map. It manages:
- 50+ refs for scene objects (renderer, camera, raycaster, star meshes, etc.)
- A 616-line animation loop running at 60fps via requestAnimationFrame
- Bidirectional state bridging between React (declarative) and Three.js (imperative)
- Event coordination across routing, selection, hover, camera, and cinematic mode
The problem with splitting: circular dependencies. The animation loop needs refs from scene setup. Scene setup needs callbacks that update state. State updates trigger re-renders that must not recreate the Three.js scene. Everything is interconnected.
We attempted to extract a SceneManager class (December 2024). After 3 days: orphaned code fragments, broken brace matching, and ref timing bugs that only appeared in production. We reverted and documented the failure in docs/working_memory/2025-11-21_threejs-scene-extraction.md.
The Real Solution: Custom Hooks
Instead of splitting the file, we extracted self-contained logic into custom hooks. The key insight: hooks that don't need direct scene refs can live outside App.tsx.
Hooks We Extracted (15+)
| Hook | Lines | Purpose |
|---|---|---|
useRoutingWorker |
~200 | Web Worker communication for pathfinding |
useCameraAnimation |
~150 | Smooth camera transitions with easing |
useReachability |
~180 | Jump range calculations and visualization |
useHelperBridge |
~120 | Native overlay helper protocol |
useSmartAssemblyManagement |
~250 | Smart Gate and assembly data loading |
useSolarSystemView |
~180 | Detailed system view with celestials |
useKeyboardShortcuts |
~100 | Global keybindings (Escape, R, etc.) |
The Extraction Criteria
A piece of logic is a good extraction candidate when:
- It doesn't need scene refs: No direct access to
rendererRef,cameraRef,sceneRef - State is self-contained: The hook manages its own
useState/useRefwithout reading App's refs - Communication is via callbacks: The hook exposes functions that App calls, rather than reaching into App's internals
// ✅ GOOD: Self-contained hook
function useRoutingWorker() {
const [route, setRoute] = useState(null);
const workerRef = useRef(null);
const calculate = useCallback(async (params) => {
// Worker communication is isolated
const result = await workerRef.current.postMessage(params);
setRoute(result);
}, []);
return { route, calculate };
}
// ❌ BAD: Would require scene refs
function useStarHover() {
// This needs raycasterRef, cameraRef, sceneRef...
// Keep it in App.tsx
}
What Stays in App.tsx
Some things must stay in the coordinator component:
- Scene initialization: The massive
useEffectthat creates Three.js objects - Animation loop: The
requestAnimationFramecallback reading 20+ refs - Event bridging: React state ↔ Three.js imperative updates
- Cleanup: Disposing textures, geometries, and materials on unmount
The Section Map Approach
Since App.tsx will remain large, we documented its structure in eve-frontend-map/docs/APP_TSX_ARCHITECTURE.md:
// App.tsx Section Map (~9,300 lines)
// Lines 1-200: Imports & type definitions
// Lines 200-500: State declarations (50+ refs)
// Lines 500-800: Custom hook integrations
// Lines 800-1400: Scene initialization useEffect
// Lines 1400-2000: Event handlers (click, hover, keyboard)
// Lines 2000-2600: Animation loop (requestAnimationFrame)
// Lines 2600-3200: Camera controls & transitions
// Lines 3200-4000: Routing integration
// Lines 4000-5300: Panel coordination
// Lines 5300-6000: Cinematic mode
// Lines 6000-9300: JSX render & cleanup
New contributors can jump to the relevant section without understanding the entire file.
Metrics That Matter
| Metric | Before Hooks | After Hooks |
|---|---|---|
| App.tsx lines | ~12,000 | ~9,300 |
| Extracted hooks | 0 | 15+ |
| Testable units | 1 (integration only) | 15+ (unit testable) |
| Bug surface area | Entire file | Isolated to hook |
When IS It Right to Split?
This approach isn't universal. Split your component when:
- No shared refs: Components don't need access to the same mutable objects
- Clear boundaries: State flows in one direction (parent → child)
- Independent lifecycles: Components can mount/unmount independently
Our UI panels (RoutingPanel.tsx, SearchPanel.tsx, etc.) are correctly split—they receive props and emit events, no shared refs needed.
Lessons for Large Codebases
- Document decisions: We recorded the failed extraction attempt. Future developers won't repeat it.
- Extract what you can: Even partial extraction (hooks) improves testability.
- Section maps beat refactoring: For truly interconnected code, navigation docs are more valuable than forced splits.
- Measure actual pain: A 9,000-line file that works is better than a fractured architecture with subtle bugs.
"The best refactoring is the one that reduces bugs without introducing new ones. Sometimes that means not refactoring at all."
Related Posts
- Web Workers: Keeping the UI Responsive - How useRoutingWorker communicates with our pathfinding worker
- Vibe Coding: AI-Assisted Development - How we use AI to navigate large files safely
- Exploration Mode: Real-Time Pathfinding Visualization - A feature built using extracted hooks
- Quick Tour: Interactive Onboarding with Driver.js - Another hook extraction success story