3.5 KiB
React Component Extraction — Mega-File Split Pattern
When you need this
A single .tsx file has grown past ~1,000 lines with inline sub-components, inline helper functions, and inline type definitions. The established pattern is:
- Pure helpers →
src/lib/<domain>.ts— formatters, PB query builders, status label/color mappers, date formatters - Types → shared module — interfaces can stay in the page file if re-exported (for external importers), or move to
src/components/<domain>/types.ts - Inline components →
src/components/<domain>/— one file per component, barrelindex.ts - Page becomes orchestrator — imports from barrel, keeps all state + handlers
Proven pattern (from Appointments.tsx, Customers.tsx splits)
| Extraction target | Destination | Pattern |
|---|---|---|
function HelperFn(...) |
src/lib/<domain>.ts |
Pure function, no hooks, no JSX |
interface X |
src/components/<domain>/types.ts (or re-export from page) |
Re-export from page if external files import from the old path |
function Skeleton(), function EmptyState() |
EmptyStates.tsx |
Plain function component |
function Card(props): JSX |
<Name>Card.tsx |
memo(function NameCardImpl(props) { ... }) |
function FormModal(props) |
<Name>FormModal.tsx |
Keep as-is (has internal state — memo adds nothing) |
function DetailView(props) |
<Name>DetailView.tsx |
memo(function DetailViewImpl(props) { ... }) |
function DeleteConfirmModal(props) |
DeleteConfirmModal.tsx |
Keep as-is |
| Barrel | index.ts |
Re-export all components and types |
Import compatibility pattern
When external files import types from the old page path (e.g., import type { CustomerWithVehicles } from '../pages/Customers'), the page MUST re-export those types:
// src/pages/Customers.tsx — at the top level
export type { CustomerRecord, CustomerWithVehicles, CustomerFormData }
from '../components/customers/types';
Do NOT change the import paths in the 5 external files — the re-export keeps them working.
Pitfalls discovered during extraction
1. Fragment shorthand → Fragment key mismatch
The original file uses <>...</> (no key prop). The extracted equivalent needs <Fragment key={expr}>. But the closing paren/bracket structure differs because the Fragment opening tag is part of the JSX tree rather than being a structural wrapper.
Trace template:
Original: return ( <><tr/><tr/></> );
After extract: return ( <Fragment key={x}><tr/><tr/></Fragment> );
The closing )); after </> in a .map() callback becomes ); after </Fragment>, then })} for block-end, .map() close, JSX expression close. Count explicitly.
2. useCallback dependency drift
When moving a handler from the orchestrator page that references a callback defined in the same component, the useCallback deps array may reference a function that's now imported. The import is stable (never changes identity), so the dep IS needed in the array to be correct, but TypeScript won't warn either way. Check: every useCallback(fn, deps) where fn calls an imported function — that imported function should be in the deps array, or the callback is stale.
3. formatDate name collision
The page likely has its own function formatDate(...) helper. The extracted lib module uses the same name. Rename to formatCustomerDate or formatDomainDate — the app's src/lib/format.ts may already export a global formatDate.