NUCBA
20 de enero de 2026
programación

¿Tu proyecto frontend está listo para escalar?

La arquitectura frontend define si tu proyecto escala o colapsa. Separación de responsabilidades, patrones reales y decisiones que evitan deuda técnica.

Equipo NUCBA

Equipo NUCBA

8 min de lectura

¿Tu proyecto frontend está listo para escalar?

Ese componente de 800 líneas que "después refactorizamos" ya tiene tres meses. El state management está repartido entre Context API, local state y algún que otro localStorage porque "era lo más rápido". Y ahora el CEO quiere agregar una funcionalidad que toca cinco áreas diferentes del código. La arquitectura frontend que parecía suficiente para el MVP ahora es el cuello de botella del equipo.

La diferencia entre un proyecto que escala y uno que colapsa no está en el framework que elegís, sino en cómo organizás el código, las responsabilidades y las decisiones técnicas desde el día uno.

Por qué la arquitectura importa más que el framework

React, Vue, Angular, Svelte: todos resuelven el problema de renderizar UI. Pero ninguno te dice cómo estructurar tu lógica de negocio, dónde van las validaciones, cómo manejar side effects o cómo evitar que un cambio en checkout rompa el header.

La arquitectura frontend define:

  • Separación de responsabilidades: qué va en componentes, qué en servicios, qué en hooks
  • Flujo de datos: cómo viaja la información desde la API hasta la UI
  • Límites claros: dónde termina una feature y empieza otra
  • Testing: qué tan fácil es probar cada parte sin montar toda la app

Un ejemplo concreto: si tu componente ProductCard hace el fetch, parsea la data, maneja el loading, formatea precios y decide qué mostrar según permisos del usuario, estás acoplando seis responsabilidades diferentes. Cuando el endpoint cambie o necesites mostrar el mismo producto en otro contexto, vas a duplicar lógica o refactorizar todo.

Capas de una arquitectura frontend escalable

Capa de presentación (UI pura)

Componentes que reciben props y renderizan. Sin lógica de negocio, sin fetching, sin decisiones complejas.

// ❌ Componente con demasiadas responsabilidades
function ProductCard({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data);
        setLoading(false);
      });
  }, [productId]);
  
  const price = product?.price 
    ? new Intl.NumberFormat('es-AR', { style: 'currency', currency: 'ARS' }).format(product.price)
    : '-';
  
  const canBuy = product?.stock > 0 && user?.role === 'customer';
  
  return <div>{/* UI */}</div>;
}

// ✅ Componente de presentación puro
function ProductCard({ 
  name, 
  formattedPrice, 
  imageUrl, 
  canBuy, 
  onBuy 
}: ProductCardProps) {
  return (
    <article className="product-card">
      <img src={imageUrl} alt={name} />
      <h3>{name}</h3>
      <p className="price">{formattedPrice}</p>
      {canBuy && <button onClick={onBuy}>Comprar</button>}
    </article>
  );
}

Capa de lógica (hooks, composables, view models)

Acá vive el estado, los side effects, las transformaciones de datos.

// hooks/useProduct.ts
export function useProduct(productId: string) {
  const { data, isLoading, error } = useQuery(
    ['product', productId],
    () => productService.getById(productId)
  );
  
  const formattedPrice = data?.price 
    ? formatCurrency(data.price, 'ARS')
    : '-';
  
  const canBuy = data 
    ? data.stock > 0 && hasPermission('buy')
    : false;
  
  return {
    product: data,
    formattedPrice,
    canBuy,
    isLoading,
    error
  };
}

Capa de servicios (acceso a datos)

Toda comunicación con APIs externas. Un solo lugar para cambiar endpoints, headers, manejo de errores.

// services/productService.ts
export const productService = {
  getById: async (id: string): Promise<Product> => {
    const response = await apiClient.get(`/products/${id}`);
    return productMapper.toDomain(response.data);
  },
  
  search: async (query: string): Promise<Product[]> => {
    const response = await apiClient.get('/products', { params: { q: query } });
    return response.data.map(productMapper.toDomain);
  }
};

Capa de dominio (entidades, reglas de negocio)

Los tipos, las validaciones, las reglas que no dependen de UI ni de infraestructura.

// domain/product.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

export function canPurchaseProduct(product: Product, quantity: number): boolean {
  return product.stock >= quantity && product.price > 0;
}

// mappers/productMapper.ts
export const productMapper = {
  toDomain: (dto: ProductDTO): Product => ({
    id: dto.id,
    name: dto.product_name, // transformación de naming
    price: dto.price_in_cents / 100, // de centavos a pesos
    stock: dto.available_quantity
  })
};

Patrones que salvan proyectos reales

Feature folders vs tipo de archivo

Organizá por feature, no por tipo de archivo:

❌ src/
  ├── components/
  │   ├── ProductCard.tsx
  │   ├── ProductList.tsx
  │   ├── UserProfile.tsx
  │   └── CartButton.tsx
  ├── hooks/
  │   ├── useProduct.ts
  │   ├── useCart.ts
  │   └── useUser.ts
  └── services/
      ├── productService.ts
      └── userService.ts

✅ src/
  ├── features/
  │   ├── products/
  │   │   ├── components/
  │   │   ├── hooks/
  │   │   ├── services/
  │   │   └── types.ts
  │   ├── cart/
  │   └── user/
  └── shared/

Cuando necesitás borrar o modificar una feature, todo está en un solo lugar. No tenés que buscar en cinco carpetas diferentes.

Barrel exports controlados

// features/products/index.ts
export { ProductCard } from './components/ProductCard';
export { ProductList } from './components/ProductList';
export { useProduct } from './hooks/useProduct';
export type { Product } from './types';

// ❌ NO expongas todo
// export * from './components';

Controlás qué es público y qué es privado de cada feature.

Container/Presenter pattern

Separás componentes que manejan lógica (containers) de componentes que solo renderizan (presenters):

// ProductCardContainer.tsx (container)
export function ProductCardContainer({ productId }: { productId: string }) {
  const { product, formattedPrice, canBuy, isLoading } = useProduct(productId);
  const { addToCart } = useCart();
  
  if (isLoading) return <ProductCardSkeleton />;
  if (!product) return null;
  
  return (
    <ProductCard
      name={product.name}
      formattedPrice={formattedPrice}
      imageUrl={product.imageUrl}
      canBuy={canBuy}
      onBuy={() => addToCart(product)}
    />
  );
}

Los presenters son fáciles de testear (solo props) y de reusar en Storybook.

State management: menos es más

No necesitás Redux/Zustand/Jotai para todo. Preguntate primero:

  1. Server state (datos de API): React Query, SWR, Apollo
  2. URL state (filtros, paginación): search params, router state
  3. Component state (modales, tabs locales): useState
  4. Shared UI state (theme, sidebar abierto): Context API
  5. Global app state (usuario, carrito): acá sí considerar Zustand/Redux

Ejemplo de server state bien manejado:

// ✅ React Query cachea, revalida y sincroniza automáticamente
function ProductList() {
  const { data: products } = useQuery('products', productService.getAll);
  return products?.map(p => <ProductCard key={p.id} {...p} />);
}

// ❌ No necesitás poner esto en Redux
// const products = useSelector(state => state.products.list);

Decisiones que generan deuda técnica

1. Componentes que hacen todo

Ya lo vimos: componentes de 500+ líneas que mezclan UI, lógica, fetching y validaciones. Imposibles de testear y reusar.

2. Prop drilling infinito

Pasar props por cinco niveles porque "después refactorizamos":

// ❌ Prop drilling
<App user={user}>
  <Layout user={user}>
    <Sidebar user={user}>
      <UserMenu user={user} />
    </Sidebar>
  </Layout>
</App>

// ✅ Context para datos globales
const UserContext = createContext<User | null>(null);

function App() {
  const user = useAuth();
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

3. Lógica de negocio en componentes

Validaciones, cálculos, reglas: todo eso debe vivir fuera de los componentes.

// ❌ Lógica mezclada con UI
function CheckoutForm() {
  const handleSubmit = (data) => {
    if (data.items.length === 0) return;
    const total = data.items.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    );
    if (total < 1000) {
      alert('Compra mínima $1000');
      return;
    }
    // ...
  };
}

// ✅ Lógica en funciones puras
function validateOrder(order: Order): ValidationResult {
  if (order.items.length === 0) {
    return { valid: false, error: 'El carrito está vacío' };
  }
  const total = calculateOrderTotal(order);
  if (total < MIN_ORDER_AMOUNT) {
    return { valid: false, error: `Compra mínima $${MIN_ORDER_AMOUNT}` };
  }
  return { valid: true };
}

4. Dependencias directas a librerías externas

Si importás axios o date-fns en 40 componentes, cambiar de librería es un infierno. Usá abstracciones:

// utils/http.ts
import axios from 'axios';

export const http = {
  get: axios.get,
  post: axios.post,
  // ...
};

// Ahora si cambiás a fetch nativo, solo tocás un archivo

Testing como indicador de arquitectura

Si tus tests son difíciles de escribir, tu arquitectura tiene problemas:

  • Necesitás mockear 10 cosas para testear un componente: está acoplado a demasiadas dependencias
  • Tenés que montar toda la app para testear una función: la lógica está mezclada con UI
  • Los tests se rompen cuando cambiás CSS: estás testeando implementación en vez de comportamiento

Ejemplo de componente testeable:

// ✅ Fácil de testear (solo props)
describe('ProductCard', () => {
  it('muestra el botón de compra solo si canBuy es true', () => {
    const { getByText, queryByText } = render(
      <ProductCard 
        name="Remera"
        formattedPrice="$5.000"
        canBuy={false}
        onBuy={() => {}}
      />
    );
    
    expect(queryByText('Comprar')).not.toBeInTheDocument();
  });
});

Preguntas frecuentes

¿Cuándo necesito pensar en arquitectura?
Desde el primer día. Reorganizar un proyecto con 50 componentes es manejable. Reorganizar uno con 500 es un proyecto de meses. Las decisiones de estructura se pagan (o se cobran) con interés compuesto.

¿Qué hago si ya tengo un proyecto caótico?
Estrategia del estrangulador: no reescribas todo. Empezá a aplicar buenas prácticas en features nuevas y refactorizá las viejas de a una cuando las tengas que tocar. En seis meses vas a tener un proyecto mixto pero funcional. En un año, limpio.

¿Necesito arquitectura hexagonal/clean architecture en frontend?
Para la mayoría de los proyectos es overkill. Con separar presentación, lógica, servicios y dominio ya ganás el 80% de los beneficios. Si estás haciendo una app enterprise con 10+ devs, ahí sí consideralo.

Checklist para evaluar tu arquitectura actual

  • ¿Podés entender qué hace un componente en menos de 30 segundos?
  • ¿Los componentes de UI reciben datos, no los buscan?
  • ¿La lógica de negocio está en funciones puras y testeables?
  • ¿Podés cambiar de librería de fetching sin tocar componentes?
  • ¿Las features están organizadas por dominio, no por tipo de archivo?
  • ¿Un dev nuevo puede agregar una feature sin tocar código no relacionado?
  • ¿Los tests son rápidos y no requieren setup complejo?

¿Te gustó este artículo?

Descubre nuestros cursos y carreras para llevar tus habilidades al siguiente nivel.