Señales de un componente difícil de mantener en React

Gabriel Jiménez | Hace 10 días

Cuando una aplicación crece, también lo hace la complejidad de sus componentes. Lo que al inicio parecía simple, con el tiempo puede convertirse en código difícil de entender, modificar y probar.


Detectar estas señales temprano puede ayudarnos a evitar errores, regresiones y componentes que generan miedo cada vez que necesitamos cambiar algo.


En este artículo veremos algunas señales comunes de un componente difícil de mantener, cómo identificarlas y ejemplos prácticos que muestran por qué pueden convertirse en un problema conforme nuestra aplicación evoluciona.


Tiene demasiadas responsabilidades

Una de las señales más comunes de un componente difícil de mantener es cuando intenta hacer demasiadas cosas al mismo tiempo.


Por ejemplo, un componente que obtiene información del backend, procesa datos, maneja formularios, controla modales, renderiza la interfaz y además contiene lógica de negocio, rápidamente se vuelve complicado de entender y modificar.


Mientras más responsabilidades tenga un componente, más difícil será detectar errores, reutilizar lógica y escribir pruebas sin romper otras funcionalidades.


Ejemplo


export default function UserDashboard() {
  // Obtiene datos del usuario
  const [user, setUser] = useState(null);

  // Consulta órdenes
  const [orders, setOrders] = useState([]);

  // Contiene lógica de negocio
  const [message, setMessage] = useState("");

  useEffect(() => {
    fetch("/api/user")
      .then((response) => response.json())
      .then((userData) => {
        setUser(userData);

        // Consulta órdenes
        return fetch(`/api/users/${userData.id}/orders`);
      })
      .then((response) => response.json())
      .then((ordersData) => {
        setOrders(ordersData);

        // Procesa información
        const total = ordersData.reduce((sum, order) => {
          return sum + order.amount;
        }, 0);

        // Contiene lógica de negocio
        if (total > 1000) {
          setMessage("Cliente premium");
        } else {
          setMessage("Cliente regular");
        }
      });
  }, []);

  // Renderiza la interfaz
  return (
    <div>
      <h1>Dashboard</h1>

      <p>{message}</p>

      {orders.map((order) => (
        <div key={order.id}>
          {order.name}
        </div>
      ))}
    </div>
  );
}

Una probable solución sería separar responsabilidades para que el componente no tenga que encargarse de todo.


// useApi.js

/**
 * Centralizar las peticiones HTTP en un hook o servicio
 * evita repetir lógica dentro de los componentes.
 *
 * Si mañana cambia la forma de consumir la API,
 * solo modificamos este archivo y no todos los componentes.
 *
 * Además, facilita las pruebas y reutilización.
 */
export function useApi() {
  const getUser = async () => {
    const response = await fetch("/api/user");
    return response.json();
  };

  const getUserOrders = async (userId) => {
    const response = await fetch(`/api/users/${userId}/orders`);
    return response.json();
  };

  return {
    getUser,
    getUserOrders,
  };
}

// OrderUtils.js

/**
 * Separar cálculos y reglas de negocio en utilidades
 * evita mezclar lógica compleja con la interfaz.
 *
 * Esto hace que el componente sea más fácil de leer,
 * reutilizar y probar de forma aislada.
 */
export class OrderUtils {
  static calculateTotal(orders) {
    return orders.reduce((sum, order) => {
      return sum + order.amount;
    }, 0);
  }

  static getCustomerMessage(total) {
    return total > 1000 ? "Cliente premium" : "Cliente regular";
  }
}

// UserDashboard.js

import { useEffect, useState } from "react";
import { useApi } from "./useApi";
import { OrderUtils } from "./OrderUtils";

export default function UserDashboard() {
  const [orders, setOrders] = useState([]);
  const [message, setMessage] = useState("");

  const { getUser, getUserOrders } = useApi();

  useEffect(() => {
    async function loadDashboard() {
      const user = await getUser();
      const ordersData = await getUserOrders(user.id);

      const total = OrderUtils.calculateTotal(ordersData);
      const customerMessage = OrderUtils.getCustomerMessage(total);

      setOrders(ordersData);
      setMessage(customerMessage);
    }

    loadDashboard();
  }, []);

  return (
    <div>
      <h1>Dashboard</h1>

      <p>{message}</p>

      {orders.map((order) => (
        <div key={order.id}>{order.name}</div>
      ))}
    </div>
  );
}

Ahora el componente tiene una única responsabilidad principal: mostrar información en pantalla.


La lógica HTTP fue movida a un useApi, mientras que las reglas de negocio y cálculos quedaron dentro de OrderUtils. Esto hace que el componente sea más fácil de leer, mantener y probar.


Además, si en el futuro cambia la API o la lógica de cálculo, podremos modificar esas partes sin tocar directamente el componente.


Cambiar algo rompe otra funcionalidad

Otra señal de un componente difícil de mantener aparece cuando agregar una funcionalidad nueva rompe algo que ya funcionaba.


Esto suele pasar cuando dos partes del componente dependen del mismo estado o de la misma lógica. Al principio puede parecer cómodo compartir esa información, pero conforme agregamos nuevos comportamientos, una funcionalidad empieza a afectar a otra.


Ejemplo


import { useState } from "react";

function ProductSearch({ value, onChange }) {
  return (
    <input
      type="text"
      placeholder="Buscar producto"
      value={value}
      onChange={(event) => onChange(event.target.value)}
    />
  );
}

function ProductList({ products }) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

export default function ProductPage() {
  const [search, setSearch] = useState("");

  const products = [
    { id: 1, name: "Laptop" },
    { id: 2, name: "Mouse" },
    { id: 3, name: "Teclado" },
  ];

  const filteredProducts = products.filter((product) => {
    return product.name.toLowerCase().includes(search.toLowerCase());
  });

  return (
    <div>
      <ProductSearch value={search} onChange={setSearch} />
      <ProductList products={filteredProducts} />
    </div>
  );
}

Hasta aquí todo funciona bien. ProductSearch modifica el texto de búsqueda y ProductList muestra los productos filtrados.


Ahora imaginemos que agregamos una nueva funcionalidad: mostrar un mensaje cuando no existan resultados.


import { useState } from "react";

function ProductSearch({ value, onChange }) {
  return (
    <input
      type="text"
      placeholder="Buscar producto"
      value={value}
      onChange={(event) => onChange(event.target.value)}
    />
  );
}

function ProductList({ products }) {
  if (products.length === 0) {
    return <p>No se encontraron productos</p>;
  }

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

export default function ProductPage() {
  const [search, setSearch] = useState("");

  const products = [
    { id: 1, name: "Laptop" },
    { id: 2, name: "Mouse" },
    { id: 3, name: "Teclado" },
  ];

  const filteredProducts = products.filter((product) => {
    return product.name.toLowerCase().includes(search.toLowerCase());
  });

  return (
    <div>
      <ProductSearch value={search} onChange={setSearch} />
      <ProductList products={filteredProducts} />
    </div>
  );
}

El problema es que ahora ProductList también muestra el mensaje cuando la lista está vacía desde el inicio. Por ejemplo, si el backend todavía no ha cargado productos, el usuario verá “No se encontraron productos”, aunque en realidad los productos siguen cargando.


Es decir, una funcionalidad pensada para la búsqueda terminó afectando el estado inicial de la lista. Ahí aparece la señal: cambiar algo pequeño en un componente rompe o modifica el comportamiento de otra parte.


Tienes demasiados useState

El uso excesivo de useState es una señal fuerte de que nuestro componente está haciendo demasiadas cosas o simplemente no fue diseñado de la mejor manera.


Cuando un componente comienza a tener demasiados estados, entender qué modifica cada uno se vuelve más complicado. Además, conforme agregamos nuevas funcionalidades, aumentan las probabilidades de introducir errores o comportamientos inesperados.


En muchos casos, demasiados useState indican que el componente necesita dividir responsabilidades, mover lógica a hooks personalizados o incluso separar partes de la interfaz en componentes más pequeños.


Ejemplo


import { useState } from "react";

export default function RegisterForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [phone, setPhone] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();

    console.log({
      name,
      email,
      password,
      phone,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Nombre"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />

      <input
        placeholder="Correo"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

      <input
        placeholder="Contraseña"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />

      <input
        placeholder="Teléfono"
        value={phone}
        onChange={(e) => setPhone(e.target.value)}
      />

      <button type="submit">
        Registrar
      </button>
    </form>
  );
}

En este ejemplo todavía es fácil de entender, pero imagina que después agregamos más campos, errores, estados de carga, mensajes de éxito y validaciones. El componente empezaría a llenarse de estados relacionados entre sí.


Para este caso, una estrategia sería encapsular la responsabilidad del formulario en un único estado.


import { useState } from "react";

export default function RegisterForm() {
  const [form, setForm] = useState({
    values: {
      name: "",
      email: "",
      password: "",
      phone: "",
    },
  });

  const handleChange = (event) => {
    const { name, value } = event.target;

    setForm((currentForm) => ({
      ...currentForm,
      values: {
        ...currentForm.values,
        [name]: value,
      },
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    console.log(form.values);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        placeholder="Nombre"
        value={form.values.name}
        onChange={handleChange}
      />

      <input
        name="email"
        placeholder="Correo"
        value={form.values.email}
        onChange={handleChange}
      />

      <input
        name="password"
        placeholder="Contraseña"
        value={form.values.password}
        onChange={handleChange}
      />

      <input
        name="phone"
        placeholder="Teléfono"
        value={form.values.phone}
        onChange={handleChange}
      />

      <button type="submit">Registrar</button>
    </form>
  );
}

Si los estados tienen algo en común entre sí, tal vez es momento de agruparlos en uno solo. En este caso, todos los valores pertenecen al mismo formulario, por eso tiene sentido administrarlos desde un mismo objeto.


JSX extenso y difícil de leer

Otra señal común de un componente difícil de mantener es cuando el JSX comienza a crecer demasiado y se vuelve complicado de leer.


Al inicio, tener toda la interfaz en un solo componente puede parecer práctico. Sin embargo, conforme agregamos condiciones, secciones, validaciones y diferentes tipos de renderizado, el componente termina convirtiéndose en un bloque enorme difícil de entender.


Cuando esto ocurre, encontrar errores, modificar la interfaz o incluso identificar qué hace cada sección empieza a tomar más tiempo.


Ejemplo


export default function Dashboard({
  user,
  notifications,
  orders,
}) {
  return (
    <div>
      <header>
        <h1>Bienvenido {user.name}</h1>

        {user.isPremium && (
          <span>Cliente premium</span>
        )}
      </header>

      <section>
        <h2>Notificaciones</h2>

        {notifications.length === 0 && (
          <p>No tienes notificaciones</p>
        )}

        {notifications.length > 0 && (
          <ul>
            {notifications.map((notification) => (
              <li key={notification.id}>
                <strong>{notification.title}</strong>

                <p>{notification.message}</p>

                {notification.read ? (
                  <span>Leída</span>
                ) : (
                  <button>
                    Marcar como leída
                  </button>
                )}
              </li>
            ))}
          </ul>
        )}
      </section>

      <section>
        <h2>Órdenes</h2>

        {orders.length === 0 && (
          <p>No tienes órdenes</p>
        )}

        {orders.length > 0 && (
          <table>
            <tbody>
              {orders.map((order) => (
                <tr key={order.id}>
                  <td>{order.name}</td>
                  <td>{order.total}</td>

                  <td>
                    {order.status === "paid" && (
                      <span>Pagada</span>
                    )}

                    {order.status === "pending" && (
                      <button>Pagar</button>
                    )}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </section>
    </div>
  );
}

Aunque el componente todavía funciona, el JSX ya comienza a ser difícil de recorrer visualmente. Existen múltiples condiciones, listas y estructuras anidadas dentro del mismo archivo.


Una estrategia común para mejorar esto es dividir partes de la interfaz en componentes más pequeños.


export default function Dashboard({
  user,
  notifications,
  orders,
}) {
  return (
    <div>
      <DashboardHeader user={user} />

      <NotificationsSection
        notifications={notifications}
      />

      <OrdersSection orders={orders} />
    </div>
  );
}

Ahora cada componente tiene una responsabilidad más clara y el componente principal es mucho más fácil de leer.


Ahora cada componente tiene una responsabilidad más clara y el componente principal es mucho más fácil de leer. Estos componentes incluso pueden vivir dentro del mismo archivo mientras su complejidad siga siendo pequeña; en caso contrario, si comienzan a crecer, tener lógica propia o reutilizarse en otras partes de la aplicación, moverlos a archivos independientes suele ser una mejor opción.


Demasiadas condiciones

Otra señal común de un componente difícil de mantener aparece cuando el JSX o la lógica comienzan a llenarse de condiciones.


Al inicio, una o dos validaciones no representan un problema. Sin embargo, conforme agregamos más reglas de negocio, permisos, estados y comportamientos dinámicos, el componente empieza a volverse más complicado de entender.


Demasiadas condiciones hacen que el flujo del componente sea difícil de seguir y aumentan las probabilidades de introducir errores al agregar nuevas funcionalidades.


Ejemplo


export default function UserActions({
  user,
  loading,
  error,
}) {
  if (loading) {
    return <p>Cargando...</p>;
  }

  if (error) {
    return <p>Ocurrió un error</p>;
  }

  return (
    <div>
      {user.isAdmin && (
        <button>
          Administrar usuarios
        </button>
      )}

      {!user.isAdmin && user.isPremium && (
        <button>
          Acceder contenido premium
        </button>
      )}

      {!user.isAdmin &&
        !user.isPremium &&
        user.isActive && (
          <button>
            Mejorar plan
          </button>
        )}

      {!user.isActive && (
        <button>
          Activar cuenta
        </button>
      )}

      {user.isAdmin &&
        user.isActive &&
        user.hasReportsAccess && (
          <button>
            Ver reportes
          </button>
        )}
    </div>
  );
}

Aunque el componente todavía funciona, entender qué botón aparece para cada tipo de usuario ya empieza a ser complicado.


El problema crece cuando necesitamos agregar nuevas reglas. Por ejemplo:


  • Usuarios suspendidos
  • Nuevos roles
  • Permisos especiales
  • Planes adicionales

Cada nueva condición aumenta la complejidad del componente y hace más difícil detectar errores o entender qué escenarios están cubiertos.


Una estrategia común es mover estas reglas a funciones descriptivas o separar responsabilidades en componentes más pequeños.


function canViewReports(user) {
  return (
    user.isAdmin &&
    user.isActive &&
    user.hasReportsAccess
  );
}

function shouldShowUpgrade(user) {
  return (
    !user.isAdmin &&
    !user.isPremium &&
    user.isActive
  );
}

Ahora el JSX es más fácil de leer porque el componente deja de contener directamente toda la lógica condicional.


Los useEffects son impredecibles

Los useEffect pueden convertirse rápidamente en una fuente de bugs difíciles de detectar cuando comienzan a depender de demasiados estados o mezclan múltiples responsabilidades dentro del mismo efecto.


En estos escenarios, entender cuándo se ejecuta el efecto y qué lo dispara deja de ser evidente.


Ejemplo

import { useEffect, useState } from "react";

export default function ProductList() {
  const [search, setSearch] = useState("");
  const [products, setProducts] = useState([]);
  const [total, setTotal] = useState(0);

  useEffect(() => {
    fetch(`/api/products?search=${search}`)
      .then((response) => response.json())
      .then((data) => {
        setProducts(data);

        const totalProducts = data.reduce(
          (sum, product) => {
            return sum + product.price;
          },
          0
        );

        setTotal(totalProducts);
      });
  }, [search, total]);

  return (
    <div>
      <input
        placeholder="Buscar producto"
        value={search}
        onChange={(event) =>
          setSearch(event.target.value)
        }
      />

      <p>Total: {total}</p>

      {products.map((product) => (
        <div key={product.id}>
          {product.name}
        </div>
      ))}
    </div>
  );
}

El problema se encuentra aquí:


useEffect(() => {
  ...
  setTotal(totalProducts);
}, [search, total]);

El efecto depende de total, pero dentro del mismo efecto también se modifica total.


Esto provoca que el useEffect vuelva a ejecutarse después de cada actualización del estado, generando renderizados y peticiones innecesarias.


Una estrategia común es mantener los efectos enfocados en una sola responsabilidad.


useEffect(() => {
  fetch(`/api/products?search=${search}`)
    .then((response) => response.json())
    .then((data) => {
      setProducts(data);
    });
}, [search]);

const total = products.reduce((sum, product) => {
  return sum + product.price;
}, 0);

Ahora el useEffect solamente se encarga de obtener productos y el cálculo del total ocurre fuera del efecto, haciendo el flujo mucho más fácil de entender.


Lógica repetida

Los componentes difíciles de probar son aquellos donde simular su comportamiento requiere demasiado esfuerzo.


Esto suele pasar cuando el componente depende directamente de detalles externos, como llamadas HTTP, localStorage, fechas, navegación o librerías externas. Mientras más dependencias tenga mezcladas dentro del componente, más difícil será probarlo de forma aislada.


Ejemplo


import { useEffect, useState } from "react";

export default function UserDashboard() {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  const [total, setTotal] = useState(0);
  const [message, setMessage] = useState("");

  useEffect(() => {
    fetch("/api/user")
      .then((response) => response.json())
      .then((userData) => {
        setUser(userData);

        return fetch(`/api/users/${userData.id}/orders`);
      })
      .then((response) => response.json())
      .then((ordersData) => {
        setOrders(ordersData);

        const totalAmount = ordersData.reduce((sum, order) => {
          return sum + order.amount;
        }, 0);

        setTotal(totalAmount);

        if (totalAmount > 1000) {
          setMessage("Cliente premium");
        } else {
          setMessage("Cliente regular");
        }
      });
  }, []);

  return (
    <div>
      <h1>Dashboard del usuario</h1>

      {user && <h2>{user.name}</h2>}

      <p>Total de compras: ${total}</p>

      <p>{message}</p>

      <ul>
        {orders.map((order) => (
          <li key={order.id}>
            Orden #{order.id} - ${order.amount}
          </li>
        ))}
      </ul>
    </div>
  );
}

Para probar este componente, tendríamos que investigar cómo simular fetch. Hoy en día, con ayuda de la IA, esto puede parecer sencillo, pero cuando una aplicación crece, la rapidez con la que podemos escribir pruebas y liberar nuevas funcionalidades cobra mayor relevancia.


Una forma de mejorar este caso es pasar las funciones como props. De esta manera, el componente ya no necesita saber si los datos vienen de fetch, de un mock, de un servicio o de cualquier otra fuente.


import { useEffect, useState } from "react";

export default function UserDashboard({
  getUser,
  getOrders,
}) {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  const [total, setTotal] = useState(0);
  const [message, setMessage] = useState("");

  useEffect(() => {
    getUser()
      .then((userData) => {
        setUser(userData);

        return getOrders(userData.id);
      })
      .then((ordersData) => {
        setOrders(ordersData);

        const totalAmount = ordersData.reduce((sum, order) => {
          return sum + order.amount;
        }, 0);

        setTotal(totalAmount);

        if (totalAmount > 1000) {
          setMessage("Cliente premium");
        } else {
          setMessage("Cliente regular");
        }
      });
  }, [getUser, getOrders]);

  return (
    <div>
      <h1>Dashboard del usuario</h1>

      {user && <h2>{user.name}</h2>}

      <p>Total de compras: ${total}</p>

      <p>{message}</p>

      <ul>
        {orders.map((order) => (
          <li key={order.id}>
            Orden #{order.id} - ${order.amount}
          </li>
        ))}
      </ul>
    </div>
  );
}

Ahora podemos probar el componente pasando funciones falsas que devuelvan los datos necesarios para el escenario de prueba.


import { render, screen } from "@testing-library/react";
import UserDashboard from "./UserDashboard";

test("muestra el nombre del usuario", async () => {
  render(
    <UserDashboard
      getUser={() =>
        Promise.resolve({
          id: 1,
          name: "Gabriel",
        })
      }
      getOrders={() =>
        Promise.resolve([
          { id: 1, amount: 500 },
          { id: 2, amount: 700 },
        ])
      }
    />
  );

  expect(
    await screen.findByText("Gabriel")
  ).toBeInTheDocument();
});

Con este cambio, la prueba se enfoca en validar el comportamiento del componente y no en resolver cómo simular internamente una llamada HTTP.


Conclusión

Detectar estas señales a tiempo puede ayudarnos a construir componentes más fáciles de entender, probar y mantener conforme la aplicación crece.


Muchas veces, mejorar un componente es como ir sacando pequeñas tareas de la cabeza: el código se siente más claro, más ordenado y trabajar sobre él se vuelve mucho más tranquilo.


Sigue aprendiendo cómo escalar aplicaciones React mediante un enfoque de pruebas y construye componentes más fáciles de mantener y evolucionar.


Visita mi Hub Testing en React.

Más de esta serie

Cómo consumir APIs en React sin ensuciar tus componentes

Cómo consumir APIs en React sin ensuciar tus componentes

Aprende cómo transformar datos entre React y tu backend usando adapters y mappers para crear componentes más fáciles de mantener y escalar.

Cómo organizar tus carpetas y archivos en React para escalar tu aplicación

Cómo organizar tus carpetas y archivos en React para escalar tu aplicación

Aprende a organizar carpetas y archivos en React para crear proyectos más mantenibles, escalables y fáciles de desarrollar.

¿Cuándo usar hooks personalizados en React (y cuándo no)?

¿Cuándo usar hooks personalizados en React (y cuándo no)?

Aprende cuándo crear hooks personalizados en React para reutilizar lógica, simplificar componentes y mejorar el testing.

Por qué tu componente en React es difícil de probar (y cómo solucionarlo)

Por qué tu componente en React es difícil de probar (y cómo solucionarlo)

Aprende por qué algunos componentes en React son difíciles de probar y cómo organizarlos para crear código más escalable y mantenible.