Decisiones de Refactoring en React y TypeScript
Una lista de los refactors más comunes que valen la pena en código real de React y TypeScript - cada uno con una etiqueta en negrita, una rationale de una sola oración y un ejemplo breve antes/después. Úsalo como una pre-checklist de PR, una referencia de revisión de código, o una auditoría de búsqueda de anti-patrones en un codebase existente.
Cómo usar esta lista
- Recorre cada elemento; si un patrón coincide, abre un PR pequeño y con alcance por decisión.
- Prefiere pequeños refactors pareados con el trabajo de feature que los expone - no reescrituras big-bang.
- Mantén el diff "antes" en la descripción del PR para que los revisores vean el smell que eliminaste.
Decisiones de Refactoring
-
Extrae valores mágicos a constantes nombradas - Reemplaza literales dispersos con una única fuente de verdad para que los ajustes no requieran un grep-and-pray en todo el repo.
// before if (retries < 3) retry(); // after const MAX_RETRIES = 3; if (retries < MAX_RETRIES) retry(); -
Reemplaza
anyconunknowny estrecha - Fuerza a los llamadores a probar la forma antes de usarla, capturando suposiciones malas en el límite en lugar de profundo en la UI.// before function parse(data: any) { return data.items.map(...); } // after function parse(data: unknown) { if (!isPayload(data)) throw new Error("bad payload"); return data.items.map(...); } -
Prefiere uniones discriminadas sobre props opcionales - Hace que estados imposibles sean no representables para que TypeScript rechace
loading: truecondata: Fooen tiempo de compilación.// before type State = { loading: boolean; data?: Foo; error?: Error }; // after type State = | { status: "loading" } | { status: "success"; data: Foo } | { status: "error"; error: Error }; -
Extrae efectos repetidos en un hook personalizado - Seca la lógica transversal (polling, suscripciones, trampas de foco) y la hace unit-testeable en aislamiento.
// before useEffect(() => { const id = setInterval(fetchStatus, 5000); return () => clearInterval(id); }, []); // after usePolling(fetchStatus, 5000); -
Reemplaza prop drilling con context o una store - Elimina props pass-through de componentes que no se preocupan por el valor, para que agregar un nuevo consumidor no toque las capas intermedias.
// before <Page user={user}><Toolbar user={user}><Avatar user={user} /></Toolbar></Page> // after <UserProvider value={user}><Page><Toolbar><Avatar /></Toolbar></Page></UserProvider> -
Calcula estado derivado durante el renderizado en lugar de almacenarlo - Elimina toda una clase de bugs "estos dos estados no coinciden" al hacer que la derivación sea la única fuente de verdad.
// before const [items, setItems] = useState<Item[]>([]); const [count, setCount] = useState(0); // drifts // after const [items, setItems] = useState<Item[]>([]); const count = items.length; -
Memoiza computaciones costosas con
useMemo- Salta trabajo repetido en renderizados causados por estado no relacionado, convirtiendo un lag de entrada visible en retroalimentación instantánea.const sorted = useMemo(() => bigList.sort(compareFn), [bigList]); -
Envuelve callbacks en
useCallbacksolo cuando se pasan a hijos memoizados - Mantiene los límitesReact.memoestables para que los descendientes salten re-renderizados en lugar de romper su memoización.const onSelect = useCallback((id: string) => setSelected(id), []); return <MemoizedList onSelect={onSelect} />; -
Divide componentes grandes por responsabilidad - Reduce el alcance del re-renderizado y el alcance de revisión para que un cambio en el header no cause un diff en el footer.
// before: 400-line <Dashboard /> doing header, filters, table, pagination // after <Dashboard><Header /><Filters /><Table /><Pagination /></Dashboard> -
Reemplaza
useStateenredado conuseReducer- Centraliza transiciones relacionadas en una función para que los invariantes se puedan aplicar en lugar de dispersos en los handlers.const [state, dispatch] = useReducer(cartReducer, initialCart); dispatch({ type: "add", item }); -
Convierte componentes de clase a componentes de función - Desbloquea hooks, bundles más pequeños, y patrones modernos como Suspense y Server Components.
// before class Timer extends React.Component { /* lifecycle methods */ } // after function Timer() { useEffect(() => { /* ... */ }, []); return <span /> } -
Usa
as constpara inferencia literal - Fija valores de string y array a sus tipos literales exactos para que fluyan a través de genéricos en lugar de ampliarse astring.// before const roles = ["admin", "user"]; // string[] // after const roles = ["admin", "user"] as const; // readonly ["admin", "user"] -
Reemplaza
enumcon una unión de objetoas const- Produce mejor tree-shaking, JSON más amigable, y tipos más claros sin los quirks de runtime de TypeScript enum.// before enum Status { Open, Closed } // after const Status = { Open: "open", Closed: "closed" } as const; type Status = (typeof Status)[keyof typeof Status]; -
Eleva tipos compartidos a un módulo dedicado - Previene drift de tipos cuando la misma forma se redeclara en tres componentes con campos ligeramente diferentes.
// src/types/user.ts export interface User { id: string; email: string; roles: Role[] } -
Usa
satisfiespara validar sin ampliar - Mantiene el tipo inferido estrecho mientras aún verificas que el valor se conforme, para que el autocomplete se mantenga agudo.const routes = { home: "/", admin: "/admin", } satisfies Record<string, `/${string}`>; -
Reemplaza keys de índice de array con IDs estables - Arregla bugs de reorden, pérdida de foco de input, y glitches de animación causados por React reutilizando el nodo DOM incorrecto.
// before items.map((item, i) => <Row key={i} {...item} />) // after items.map((item) => <Row key={item.id} {...item} />) -
Reemplaza fetches con
useEffectcon TanStack Query (o SWR) - Obtén caching, deduplicación, retries, y cancellación de solicitudes gratis, eliminando docenas de líneas de boilerplate de loading/error.const { data, isLoading } = useQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) }); -
Valida datos externos con Zod en el límite - Convierte crashes "undefined is not a function" profundos en la UI en un único y explícito parse failure en el borde.
const User = z.object({ id: z.string(), email: z.string().email() }); const user = User.parse(await res.json()); -
Divide un mega-context en contexts dirigidos - Detiene que cada consumidor se re-renderice cuando un slice no relacionado cambia, convirtiendo una app lenta en una snappy.
<AuthProvider> <ThemeProvider> <CartProvider>{children}</CartProvider> </ThemeProvider> </AuthProvider> -
Coloca state junto al componente que lo posee - Reduce el alcance del razonamiento y hace que la eliminación sea trivial cuando la feature desaparece.
// before: modal open state in a global store // after: useState inside <Modal /> itself const [open, setOpen] = useState(false); -
Reemplaza sopa de boolean-prop con una unión
variant- Elimina combinaciones imposibles comoprimary && danger && outlineen el nivel de tipo.// before <Button primary danger outline /> // after <Button variant="danger-outline" /> -
Extrae lógica de forma en react-hook-form + Zod - Elimina la turbulencia de input controlado, centraliza validación, y hace la forma declarativa en lugar de imperativa.
const form = useForm<FormValues>({ resolver: zodResolver(Schema) }); <input {...form.register("email")} /> -
Reemplaza ternarios anidados con early returns - Aplana branching para que el happy path lea de arriba a abajo sin sostener un parse tree en tu cabeza.
// before return loading ? <Spinner /> : error ? <Error /> : data ? <View data={data} /> : null; // after if (loading) return <Spinner />; if (error) return <Error />; if (!data) return null; return <View data={data} />; -
Convierte ops DOM imperativas a state declarativo - Se alinea con el modelo mental de React para que los re-renderizados no puedan accidentalmente deshacer una mutación manual de DOM.
// before useEffect(() => { ref.current!.classList.add("open"); }, [open]); // after <div className={open ? "open" : ""} /> -
Usa optional chaining y nullish coalescing - Reemplaza guard pyramids con una sola expresión que aún maneja explícitamente los casos null/undefined.
// before const name = user && user.profile && user.profile.name ? user.profile.name : "guest"; // after const name = user?.profile?.name ?? "guest"; -
Mueve UI estática a un Server Component - Envía menos JavaScript al cliente y mantiene data-fetching cerca de la fuente sin una roundtrip de hidratación.
// app/page.tsx (Server Component by default in App Router) export default async function Page() { const posts = await db.posts.findMany(); return <PostList posts={posts} />; } -
Lazy-carga rutas y widgets pesados - Mantiene el bundle inicial pequeño para que la primera pintura interactiva no espere en una librería de gráficos que el usuario puede nunca abrir.
const Chart = lazy(() => import("./Chart")); <Suspense fallback={<Spinner />}><Chart /></Suspense> -
Reemplaza
React.FCcon un tipo de prop explícito - Te da control sobre el contratochildreny coincide con el consenso comunitario actual sobre tipado de componentes.// before const Card: React.FC<{ title: string }> = ({ title, children }) => <div>{title}{children}</div>; // after function Card({ title, children }: { title: string; children?: React.ReactNode }) { return <div>{title}{children}</div>; } -
Envuelve subárboles riesgosos en un error boundary - Aísla el radio de blast para que un gráfico roto no derribe todo el dashboard.
<ErrorBoundary fallback={<ChartError />}> <Chart /> </ErrorBoundary> -
Suelta banderas de loading pareando Suspense con una librería de datos - Elimina ramas
isLoadinga favor de un único fallback boundary, permitiendo a los componentes asumir que los datos están presentes.<Suspense fallback={<Skeleton />}> <UserProfile id={id} /> {/* reads data via `use` or a suspending query */} </Suspense>
Aplicando la lista en orden
- Tier de tipos (2, 3, 12-15, 28): ganancias baratas en tiempo de compilación - coloca estas primero; desbloquean refactors más seguros aguas abajo.
- Tier de render-correctness (6, 10, 16, 23, 24): elimina clases de bugs, no solo ruido - prioriza en cualquier componente que haya registrado regresiones.
- Tier de rendimiento (7-9, 19, 26, 27, 30): solo después de una medición real - no especules; perfilacea primero.
- Tier de datos (17, 18, 30): usualmente la ganancia más grande de un solo PR en codebases legacy - apunta a una área a la vez.
- Tier de estructura (1, 4, 5, 11, 20, 21, 22, 25, 29): se coloca junto con el trabajo de feature; resiste la tentación de agruparlas en un mega-refactor PR.
Gotchas
- Refactoring sin tests - Las extracciones puras de render son seguras; los movimientos de lógica no. Arreglo: coloca un test de characterización antes de tocar comportamiento.
- Memoización prematura -
useMemo/useCallbacken todas partes agrega ruido sin ganancias medibles. Arreglo: solo memoiza después de un profiler trace o cuando un downstreamReact.memodepende de estabilidad referencial. - Mega-PR refactors - Un "cleanup PR" con 40 archivos es unreviewable y conflict-prone. Arreglo: una decisión por PR, stacked si es necesario.
- Refactor para features futuras - Formar código para una feature que puede nunca enviarse es sunk cost. Arreglo: refactor el minuto antes de que la feature aterrice, no semanas antes.
- Type-only refactors persiguiendo 100% strictness - Reemplazar cada
anyde una sola vez crea diffs enormes sin cambio de comportamiento. Arreglo: habilita banderas strict incrementalmente y corrige drift en el límite del módulo.
Alternativas
| Alternativa | Usa cuando | No uses cuando |
|---|---|---|
| Codemods (jscodeshift, ts-morph) | El refactor es mecánico y se repite en cientos de archivos. | El cambio necesita juicio humano por sitio. |
| Big-bang rewrite | El código viejo es fundamentalmente unsafe y no existe camino incremental. | Los refactors incrementales son posibles - casi siempre lo son. |
| Strangler fig pattern | Estás migrando de un framework o patrón legacy gradualmente. | El codebase es lo suficientemente pequeño para refactor en un barrido. |
| Deprecation comments + lint rules | No puedes refactor todos los call sites inmediatamente pero quieres prevenir nuevos usos. | El patrón ya es totalmente removible. |
FAQs
¿Cuál de estos refactors debo abordar primero?
- Comienza con el tier de tipos (elementos 2, 3, 12-15, 28) - son solo tiempo de compilación, bajo riesgo, y hacen el resto más seguro.
- Luego toca los refactors de capa de datos (elementos 17, 18, 30) para la mayor reducción de código de un único PR.
- Deja rendimiento (elementos 7-9) para último - solo toca después de un real profiler trace.
¿Cuándo es useMemo o useCallback realmente worth it?
- Cuando el valor se pasa a un hijo
React.memo'd que de otra manera se re-renderizaría en cada parent render. - Cuando la computación en sí es measurably expensive (sorting/filtering listas grandes, derivación de datos pesada).
- No como default - la memoización especulativa agrega ruido y puede incluso ralentizar las cosas vía deps array churn.
¿Por qué preferir uniones discriminadas sobre props opcionales?
- Hacen que estados imposibles sean no representables -
loading: truecondata: Foosimplemente no type-checkará. - Los consumidores pueden exhaustivamente switchear en el discriminador y dejar que TypeScript atrape casos faltantes.
- Los props opcionales te dejan accidentalmente olvidar limpiar
errorcuando loading reinicia - las uniones no.
¿Siempre debo reemplazar enum con objetos as const?
- Para lookups tipo string, sí - mejor tree-shaking, JSON más limpio, sin sorpresas de reverse-mapping enum de runtime.
- Los numeric enums pueden quedarse si estás haciendo bitwise flags o necesitas iteración sobre valores.
- La mayor ganancia es que los objetos
as constcomponen naturalmente consatisfiesy tipos de template literal.
¿Cómo sé cuándo dividir un big context?
- Si cambiar un slice (theme) re-renderiza consumidores de slices no relacionados (cart), has superado un single context.
- Un quick React DevTools profile en una interacción dirigida mostrará la cascada de re-renderizado claramente.
- Divide por frecuencia de actualización: valores que se mueven lentamente (auth, theme) en un context, valores que se mueven rápido (cart, selection) en otro o una store.
¿No es any a veces inevitable?
- Casi nunca en código de producto -
unknownmás una función de narrowing es más seguro en cada caso en queanyes tentador. - Los internals de librería que lidia con generics verdaderamente dinámicos (Zod, TanStack Query) son la excepción honesta.
- Si debes usar
any, confínalo a una sola función y comenta por qué - no lo dejes filtrar a través de límites de módulo.
¿Cuándo debo refactor un componente de clase a un componente de función?
- Cuando ya estás tocando el archivo - no lo hagas como un standalone PR a menos que un hook sea específicamente necesario.
- Cuando los lifecycle methods son un pobre fit para la lógica (p.ej., subscription cleanup disperso en tres métodos).
- Nunca como un Friday-afternoon sweep - las clases funcionan bien; el valor está en desbloquear hooks o features modernas.
¿Qué tan agresivo debo ser sobre eliminar useEffect para data fetching?
- Muy - el fetching basado en effect se pierde caching, dedupe, retries, y cancellación que las librerías te dan gratis.
- Migra un hot path (usualmente el dashboard o list view) y mira cómo desaparece el boilerplate de loading-state.
- Reserva
useEffectpara true side-effects al mundo exterior: suscripciones, timers, APIs imperativas.
¿Por qué no solo enviar un big refactor PR?
- Los PRs grandes son unreviewable - los revisores los rubber-stamp y regresos sutiles se cuelan.
- Los conflictos de rebase componen con team velocity; los PRs pequeños aterrizan antes de que los conflictos importen.
- Una-decisión-por-PR te da un clean git history y un fácil revert path cuando algo se rompe.
¿Cuál es la cobertura de test mínima antes de refactoring?
- Al menos un integration test por user-visible path que estés tocando - no importa si es Playwright o Testing Library.
- Los refactors type-only son seguros sin tests porque un miss surfaceará en tiempo de compilación.
- Los logic moves (reducer consolidation, hook extraction) necesitan un before-test que bloquee el comportamiento actual en lugar.
¿Cómo dejo de acumular llamadas useState en un single componente?
- Si dos o más campos actualizan juntos, fold ellos en un
useReducercon acciones nombradas. - Si el valor derivado puede computarse desde estado existente, cómputalo durante render en lugar de almacenarlo.
- Si el state no conduce rendering, considera
useRefen su lugar.
¿Cuál es la manera más barata de encontrar candidatos de refactor en un codebase existente?
- Grep para
any,as any,@ts-expect-error, y// eslint-disable- marcan la rot. - Ejecuta el React DevTools profiler en los tres top user paths y busca re-renderizados evitables.
- Ordena archivos por line count; cualquier cosa sobre ~400 líneas es casi siempre un candidato split.
Relacionado
- React Architecture Decision Checklist - Decisiones de nivel de stack que preceden refactors de nivel de código.
- React Architecture Gotchas & Common Mistakes - Anti-patrones arquitectónicos que esta lista te ayuda a refactor out.
- TypeScript + React - Dives más profundos en los patrones de tipado referenciados arriba.
- React Fundamentals Best Practices - Reglas de nivel de componente a las que se alinean los refactors aquí.