Escenarios de ejemplo de refactorización
Veinte solicitudes de refactorización del mundo real tal como llegan — algunas como comentarios de revisión de PR, otras como quejas de stakeholders que resultan ser problemas de forma del código, otras como tareas de limpieza de un tech lead. Cada entrada muestra la solicitud tal como está escrita, el diagnóstico de lo que realmente está mal, una refactorización concisa y por qué la nueva forma es mejor.
Cómo usar esta lista
- Trátala como una checklist: repasa primero los 20 títulos y marca cuáles describen código que mantienes ahora mismo.
- La línea "Por qué funciona" es la parte que debes memorizar — te indica cuándo aplicar la misma refactorización en otro archivo.
- La mayoría de las refactorizaciones aquí son del tamaño de un PR. Si un solo ítem se convierte en un proyecto de varios días, el diagnóstico probablemente es incorrecto.
- Envía un refactor por PR. Agrupar una conversión a Server Component con un pase de tipado hace la revisión hostil.
1. "Este componente tiene 800 líneas y nadie quiere tocarlo"
"El archivo
<Dashboard />sigue creciendo cada sprint. Los revisores pasan 40 minutos en cada PR y aun así se les escapan cosas. ¿Podemos limpiarlo?"
Diagnóstico: Un componente renderiza el layout, obtiene tres recursos, los formatea, registra analíticas y posee cuatro piezas de state de UI. Viola la responsabilidad única en todos los ejes.
Refactorización:
// app/dashboard/page.tsx (Server Component)
export default async function Page() {
const [user, metrics, alerts] = await Promise.all([
getUser(), getMetrics(), getAlerts(),
]);
return <DashboardShell user={user} metrics={metrics} alerts={alerts} />;
}
// DashboardShell.tsx
export function DashboardShell({ user, metrics, alerts }: Props) {
return (
<Layout>
<Header user={user} />
<MetricsPanel metrics={metrics} />
<AlertsPanel alerts={alerts} />
</Layout>
);
}Por qué funciona: Cada hijo tiene una sola razón para cambiar. Los revisores hacen diff de un panel a la vez, y el Server Component puede obtener datos en paralelo sin un useEffect.
2. "Pasamos user por cinco componentes solo para leerlo en un botón"
"La prop
currentUserse enhebra por Layout, Sidebar, Nav, NavItem y finalmente LogoutButton. Añadir un campo al objeto user implica editar cinco archivos."
Diagnóstico: Prop drilling. Los componentes intermedios no usan user — solo la reenvían.
Refactorización:
// lib/user-context.tsx
"use client";
const UserContext = createContext<User | null>(null);
export const useUser = () => useContext(UserContext)!;
export function UserProvider({ user, children }: { user: User; children: ReactNode }) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
// LogoutButton.tsx
const user = useUser();Por qué funciona: Context aplana la dependencia. Añadir un campo nuevo toca el provider y el consumidor, no los cuatro componentes del medio.
3. "La página de producto obtiene datos dentro de useEffect y muestra un spinner en cada navegación"
"Los usuarios se quejan de que la página de producto muestra un skeleton de carga medio segundo incluso cuando ya la han visitado. ¿Podemos hacer que se sienta instantánea?"
Diagnóstico: Un Client Component obtiene datos en useEffect. El servidor no devuelve datos, el cliente se monta y luego obtiene. El flash es inevitable con esta forma.
Refactorización:
// app/products/[id]/page.tsx
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return <ProductView product={product} />;
}Por qué funciona: Mover la obtención a un Server Component integra los datos en la primera respuesta HTML. Sin spinner, sin cascada en el cliente, sin useEffect.
4. "Todo nuestro dashboard está marcado con \"use client\" y el bundle de JS pesa 600 KB"
"Lighthouse dice que enviamos 600 KB de JS para una página que es mayormente contenido estático. El tech lead señaló que todo es un Client Component."
Diagnóstico: "use client" está en la parte superior del wrapper de la ruta. Cada encabezado, sidebar y pie estático viaja al navegador como JS.
Refactorización:
// app/dashboard/page.tsx - sin directiva
export default function Page() {
return (
<Layout>
<Header /><Sidebar />
<InteractiveChart />{/* este es "use client" */}
</Layout>
);
}Por qué funciona: El límite vive en la hoja interactiva más pequeña. El JSX estático se queda en el servidor y se envía como HTML, no como JS.
5. "Hay cadenas 'admin' y 'editor' hardcodeadas por todas partes y un renombrado rompió prod"
"Cambiamos
'editor'a'author'en el esquema y tres comprobaciones siguen referenciando la cadena antigua. QA pilló una por accidente."
Diagnóstico: Literales de cadena dispersos entre componentes. Sin definición central, sin seguridad de tipos.
Refactorización:
// lib/roles.ts
export const ROLES = { admin: "admin", author: "author", viewer: "viewer" } as const;
export type Role = (typeof ROLES)[keyof typeof ROLES];
// uso
if (user.role === ROLES.author) { /* ... */ }Por qué funciona: as const convierte los valores en tipos literales. Renombrar la constante falla al compilar en cada consumidor en lugar de fallar en silencio.
6. "TypeScript está lleno de any y seguimos enviando undefined is not a function"
"Los errores en Sentry mencionan
Cannot read properties of undefined. El resultado del fetch está tipado comoanyy accedimos adata.user.profile.name."
Diagnóstico: any cortocircuita cada comprobación. La forma de la respuesta de la API es desconocida para el compilador.
Refactorización:
import { z } from "zod";
const User = z.object({ profile: z.object({ name: z.string() }) });
const res = await fetch("/api/user");
const user = User.parse(await res.json());Por qué funciona: Parsear en el límite convierte "any" en un tipo conocido. Las respuestas malas lanzan en el borde en lugar de crashear cuatro componentes más abajo.
7. "El <Button /> tiene 11 props booleanas y las llamadas son ilegibles"
"Cada llamada a botón tiene
isPrimary isLarge isLoading isDisabled isGhost isIcon ...y es imposible saber qué se renderiza."
Diagnóstico: Sopa de props booleanas. Los estados mutuamente excluyentes no se modelan como excluyentes.
Refactorización:
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
type Props = { variant?: Variant; size?: Size; loading?: boolean; icon?: ReactNode };
<Button variant="primary" size="lg" loading={isSaving}>Guardar</Button>Por qué funciona: Las uniones codifican la regla "solo una variante a la vez" en el sistema de tipos. Las combinaciones inválidas dejan de compilar.
8. "La función createInvoice recibe 9 argumentos posicionales"
"Cada sitio de llamada se ve como
createInvoice(id, '', null, true, false, 0, null, 'usd', undefined)y seguimos pasando el null equivocado."
Diagnóstico: Los argumentos posicionales se degradan rápido después de tres. El lector no puede saber qué significa cada uno sin abrir la definición.
Refactorización:
type CreateInvoiceInput = {
customerId: string;
note?: string;
draft?: boolean;
currency?: "usd" | "eur";
};
export function createInvoice(input: CreateInvoiceInput) { /* ... */ }
createInvoice({ customerId: id, draft: true, currency: "usd" });Por qué funciona: Las claves con nombre se documentan solas en el sitio de llamada. Añadir una opción nueva no rompe compatibilidad; los valores por defecto viven en un solo lugar.
9. "Cinco componentes tienen bloques useEffect casi idénticos obteniendo el mismo endpoint"
"Search, el sidebar, la insignia del inbox y el header llaman todos a
/api/notifications. Se desincronizan constantemente y uno siempre tiene caché obsoleta."
Diagnóstico: Lógica de carga de datos duplicada. Cada componente tiene su propio useEffect, su propio state de carga y su propio comportamiento de reintento.
Refactorización:
// hooks/use-notifications.ts
"use client";
import { useQuery } from "@tanstack/react-query";
export function useNotifications() {
return useQuery({
queryKey: ["notifications"],
queryFn: () => fetch("/api/notifications").then(r => r.json()),
});
}Por qué funciona: Una cache key, un fetcher, una única fuente de verdad. Las invalidaciones se propagan a todos los suscriptores automáticamente.
10. "Nuestro formulario de registro tiene 200 líneas de state y lógica de validación en el componente"
"El formulario de registro sigue creciendo a medida que añadimos campos. Las reglas de validación viven en una escalera gigante de
ify enviamos bugs de validación en cada release."
Diagnóstico: Gestión de state imperativa para un formulario que debería ser declarativo.
Refactorización:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email(),
password: z.string().min(12),
});
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(Schema),
});Por qué funciona: El esquema es la especificación. Validación, visualización de errores y tipos salen de la misma fuente — añadir un campo es un cambio de una línea.
11. "Todavía hay un class component y usa componentWillReceiveProps"
"ESLint avisa sobre
UNSAFE_componentWillReceivePropsen<NotificationBell />. El tech lead dice que lo convirtamos antes de la actualización a React 19."
Diagnóstico: Ciclo de vida de clase legacy que no tiene equivalente directo en hooks y falla bajo la doble invocación de Strict Mode.
Refactorización:
"use client";
export function NotificationBell({ count }: { count: number }) {
const [seen, setSeen] = useState(count);
useEffect(() => {
if (count > seen) playSound();
setSeen(count);
}, [count, seen]);
return <Badge value={count} />;
}Por qué funciona: useEffect más state captura la misma intención — "hacer algo cuando count cambia" — sin trampas de ciclo de vida ni binding de this.
12. "Tras borrar una fila se resalta el ítem equivocado"
"Borro la fila 2 de la tabla y la fila 3 pasa a estar 'seleccionada'. A veces se borra el ítem equivocado por completo."
Diagnóstico: key={i} en el mapeo de filas. React reutiliza nodos DOM por posición, así que el state se adjunta a la fila equivocada tras un borrado.
Refactorización:
{rows.map((row) => (
<Row key={row.id} {...row} />
))}Por qué funciona: Identidad estable. React rastrea cada fila por su id real, así que eliminar un ítem no desplaza el state interno a la siguiente.
13. "Tenemos un switch de 70 casos mapeando tipos de evento a manejadores"
"Añadir un tipo de evento nuevo implica desplazarse por 400 líneas de
case. La gente copia el bloque equivocado y rompe dos eventos a la vez."
Diagnóstico: Despacho codificado a mano. El flujo de control codifica datos.
Refactorización:
const handlers = {
"order.created": handleOrderCreated,
"order.refunded": handleOrderRefunded,
// ...
} satisfies Record<EventType, EventHandler>;
handlers[event.type](event);Por qué funciona: El despacho se convierte en datos. satisfies impone exhaustividad contra la unión, así que un manejador faltante falla al compilar.
14. "El JSX tiene ternarios triple-anidados y los revisores se niegan a aprobar"
"Esta tarjeta tiene
loading ? skeleton : error ? errorView : empty ? emptyState : data ? <Card /> : nully nadie puede leerlo."
Diagnóstico: Renderizado condicional comprimido en una sola expresión.
Refactorización:
function CardView({ state }: { state: ViewState }) {
if (state.kind === "loading") return <Skeleton />;
if (state.kind === "error") return <ErrorView error={state.error} />;
if (state.kind === "empty") return <EmptyState />;
return <Card data={state.data} />;
}Por qué funciona: Una unión discriminada + returns tempranos se lee de arriba a abajo. Cada rama es editable de forma independiente y la unión te obliga a manejar cada caso.
15. "Un tooltip personalizado usa document.querySelector para posicionarse"
"El tooltip salta al hacer scroll y falla en tests donde
documentestá mockeado."
Diagnóstico: Acceso imperativo al DOM en un árbol React. El tooltip lee el layout fuera del ciclo de commit de React.
Refactorización:
"use client";
import { useFloating, autoUpdate } from "@floating-ui/react";
const { refs, floatingStyles } = useFloating({ whileElementsMounted: autoUpdate });
<button ref={refs.setReference}>Ayuda</button>
<div ref={refs.setFloating} style={floatingStyles}>Tooltip</div>;Por qué funciona: Las refs dejan que la librería se suscriba al mismo ciclo de vida que React ya gestiona. Sin querySelector, sin nodos desmontados, sin roturas en tests.
16. "Un efecto imprime valores obsoletos — el count sigue registrando '0'"
"Registramos
countdentro deuseEffect(() => { setInterval(() => console.log(count), 1000) }, [])y siempre imprime 0 aunque la UI muestre 7."
Diagnóstico: Dependencias vacías capturan el cierre inicial. El intervalo nunca ve el state posterior.
Refactorización:
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
return () => clearInterval(id);
}, []);Por qué funciona: La ref comparte una identidad entre renders, así que el intervalo lee el valor más reciente sin volver a suscribirse en cada cambio de state.
17. "Guardamos fullName en state y se desincroniza con firstName/lastName"
"Edito el campo de nombre y el banner de nombre completo de arriba muestra el valor antiguo hasta que pierdes el foco."
Diagnóstico: State derivado guardado en useState. Dos fuentes de verdad que deben mantenerse sincronizadas manualmente.
Refactorización:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const fullName = `${firstName} ${lastName}`.trim();Por qué funciona: Los valores derivados calculados durante el renderizado siempre están frescos. No hay nada que sincronizar porque solo hay una fuente.
18. "Nuestra lista memoizada se re-renderiza con cada pulsación de tecla en el cuadro de búsqueda"
"Envolvimos
<RowList />enmemopero escribir en el padre sigue re-renderizando cada fila."
Diagnóstico: El callback onRowClick se recrea en cada render del padre. Una referencia nueva anula memo.
Refactorización:
const handleRowClick = useCallback((id: string) => navigate(`/rows/${id}`), [navigate]);
<RowList rows={rows} onRowClick={handleRowClick} />;Por qué funciona: useCallback preserva la identidad de la función entre renders. La comparación superficial de memo ahora ve la misma prop y omite el subárbol.
19. "Cada componente formatea precios y fechas de una forma ligeramente distinta"
"Tres páginas muestran precios como
$12.5,$12.50yUSD 12.50. Marketing quiere un solo formato."
Diagnóstico: Lógica de formateo duplicada. Cada archivo inventó la suya.
Refactorización:
// lib/format.ts
export const money = (cents: number, currency = "USD") =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
export const shortDate = (iso: string) =>
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(new Date(iso));Por qué funciona: Un módulo, una regla. Cambiar el formato es una edición, y cada página lo recoge en el siguiente deploy.
20. "La mitad de los componentes usan style={{}} inline y el bundle tiene 30 KB de CSS duplicado"
"El diseñador dice que el espaciado es inconsistente. Algunas tarjetas tienen
style={{ padding: 16 }}, otras usan una clase CSS, y se desincronizan."
Diagnóstico: Dos sistemas de estilos compitiendo. Los objetos de estilo inline envían CSS-in-JS por componente que nunca se puede deduplicar.
Refactorización:
// antes
<div style={{ padding: 16, borderRadius: 8, background: "white" }}>...</div>
// después
<div className="rounded-lg bg-white p-4">...</div>Por qué funciona: Las clases de Tailwind se resuelven en tiempo de build y se deduplican en el bundle. Los tokens de espaciado se vuelven aplicables en lugar de artesanales.
Relacionado
- UI Refactor Checklist — el recorrido sistemático que complementa estos ejemplos breves.
- Refactoring Decisions — las 30 decisiones de refactorización de alto impacto de las que parte esta lista.
- State & Re-render Bug Scenarios — contrapartes del lado de bugs para los ítems de refactorización 16-18.