Files
2026-07-12 10:17:17 -04:00

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:

  1. Pure helpers → src/lib/<domain>.ts — formatters, PB query builders, status label/color mappers, date formatters
  2. Types → shared module — interfaces can stay in the page file if re-exported (for external importers), or move to src/components/<domain>/types.ts
  3. Inline components → src/components/<domain>/ — one file per component, barrel index.ts
  4. 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.