Inicio / Artículos / Almacenar causas, derivar consecuencias: Diseñando un estado reactivo mínimo

Almacenar causas, derivar consecuencias: Diseñando un estado reactivo mínimo

Aprenda a identificar el estado redundante en React, reemplace las cadenas de sincronización controladas por Effect y las banderas booleanas por valores derivados y uniones de estado, y decida dónde debe residir el estado.

3592 palabras

La mayoría de los componentes React no se vuelven difíciles de modificar debido a una sola decisión errónea. Poco a poco, con cada llamada razonable a useState, la situación empeora hasta que la misma información se almacena en tres lugares diferentes y nadie puede determinar cuál versión es la oficial. Esta guía analiza una lista de productos realista que cae en esta trampa y luego muestra cómo decidir qué debe recordar realmente un componente, qué debe calcular en cada renderizado y dónde debe ubicarse cada pieza restante de estado. Al final, dispondrá de una lista de verificación concreta para reducir el estado antes de que se convierta en errores de sincronización.

Cómo una simple lista de productos acumula estado

Imagínese una pantalla de administración interna que muestra una lista de productos. Las personas pueden escribir un nombre para buscar, limitar la lista a una categoría, ordenarla por precio, seleccionar un producto y ver cuántas coincidencias quedan. La primera versión solo contiene lo que el usuario escribió y eligió:

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  // ...
}

Luego llegan las solicitudes de funcionalidades. La tabla debe mostrar los productos que coinciden, por lo que alguien agrega una variable de estado que almacena la lista filtrada:

const [filteredProducts, setFilteredProducts] = useState(products);

Un contador encima de la tabla indica cuántos productos coinciden, y esa cifra también tiene su propia variable de estado:

const [resultCount, setResultCount] = useState(products.length);

Cuando no hay coincidencias, la página debe mostrar un mensaje de estado vacío, por lo que también se agrega una bandera para ello:

const [hasResults, setHasResults] = useState(true);

Luego viene el ordenamiento, que incluye tanto la clave de ordenación elegida como una copia ordenada de la lista:

const [sortBy, setSortBy] = useState("name");
const [sortedProducts, setSortedProducts] = useState(products);

Cada cambio es pequeño y fácil de aprobar en la revisión. Sin embargo, unas semanas después comienzan los informes de errores. Al cambiar las categorías a veces se muestran las filas correctas junto al conteo incorrecto. Al borrar el cuadro de búsqueda aparece momentáneamente el mensaje “no se encontraron resultados”. Cuando una respuesta fresca de la API entrega nuevos products, la tabla sigue mostrando una lista filtrada desactualizada hasta que el usuario hace clic en algo.

El componente está repleto de estado, pero ya no puede responder a la única pregunta que importa: ¿cuál de estos valores es el real?

useState no es el culpable. El problema surge cuando un componente almacena varias versiones de información que podrían calcularse a partir de un conjunto más reducido de datos básicos. Cada valor adicional almacenado es algo más que hay que mantener al día con los demás, y mantener todo sincronizado es precisamente donde los componentes simples se vuelven frágiles.

Cada valor almacenado es otra forma de cometer un error

Los componentes necesitan estado porque cierta información debe persistir entre renders: el texto en un campo de entrada, la pestaña activa, si hay una ventana modal abierta, qué fila eligió el usuario. Esos son casos adecuados para usar estado.

El error está en considerar que “esto se muestra en la pantalla” implica necesariamente que “esto debe almacenarse”. Mira de nuevo la página del producto con todos los valores guardados en estado:

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [resultCount, setResultCount] = useState(products.length);
const [hasResults, setHasResults] = useState(true);

Cada variable tiene un nombre adecuado, pero no son independientes entre sí. La lista filtrada es una función de tres entradas:

products + search + category

El recuento es una función de la lista filtrada:

filteredProducts.length

Y la bandera de estado vacío es una función del recuento:

resultCount > 0

Unos pocos hechos reales se han expandido en varias consecuencias almacenadas. Eso abre la posibilidad de combinaciones que la interfaz nunca debería poder mostrar, como esta:

filteredProducts = []
resultCount = 4
hasResults = true

React mantendrá felizmente estos valores. Declaró tres elementos de estado independientes, por lo que React los trata como tales. Mantenerlos lógicamente consistentes es exclusivamente responsabilidad de su aplicación, y cada manejador de eventos que afecte a uno de ellos debe recordar los demás.

La documentación de React desaconseja el uso de estado redundante y duplicado precisamente por esta razón: cuando un valor puede calcularse a partir de props u otro estado durante el renderizado, almacenarlo por separado solo crea una nueva oportunidad para que las copias difieran.

La conclusión no es “llamar a useState menos veces”. Se trata de un cambio en lo que se considera estado:

Recuerda los datos que el componente no puede recuperar de ninguna otra manera. Calcula todo lo demás a partir de ellos.

Mantén las entradas en el estado y calcula lo demás

La página del producto nunca necesitó recordar resultCount. Lo que necesita recordar es lo que el usuario escribió en el cuadro de búsqueda y qué categoría eligió. Esas son decisiones tomadas por una persona, y nada en los datos del producto puede reconstruirlas. Todo lo demás se deriva de ello:

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  const filteredProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  const resultCount = filteredProducts.length;
  const hasResults = resultCount > 0;
  // ...
}

Fíjese en lo que ha desaparecido. Ya no es necesario actualizar resultCount; hasResults no tiene método de asignación, y ya no existe ninguna situación en la que un manejador actualice la lista filtrada pero olvide el conteo. Cada renderizado simplemente vuelve a calcular los resultados a partir de las entradas actuales. Una nueva cadena de búsqueda genera una nueva lista, una nueva categoría también produce una nueva lista, y si el padre pasa un array products diferente, el cálculo simplemente lo utiliza.

El componente ahora cuenta con menos variables modificables, lo cual representa una mejora mucho más significativa que tener menos líneas de código. Un valor derivado puede seguir conteniendo errores lógicos, pero nunca estará desactualizado porque algún manejador haya olvidado actualizarlo. Eso elimina toda una serie de estados a los que el componente podía llegar anteriormente.

La documentación de React ilustra la misma idea con un fullName formado a partir del nombre y el apellido: si se puede calcular durante la renderización, una variable de estado separada no aporta nada más que la posibilidad de incoherencias.

Una prueba rápida que puedes aplicar durante la revisión de código:

If I deleted this state variable,
could I reconstruct its value exactly
from current props and other state?

Si la respuesta es sí, comienza por convertirlo en un simple cálculo. El estado existe para almacenar información, no para guardar en caché cada resultado intermedio que el componente genere por casualidad en el proceso.

Cuando useEffect se convierte en un pipeline de sincronización

Una reacción común ante el estado derivado obsoleto es recurrir a useEffect para mantener la copia actualizada automáticamente. El componente productivo entonces crece de esta manera:

const [filteredProducts, setFilteredProducts] = useState(products);

useEffect(() => {
  const nextProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  setFilteredProducts(nextProducts);
}, [products, search, category]);

A continuación, un segundo efecto mantiene el conteo alineado con la lista:

useEffect(() => {
  setResultCount(filteredProducts.length);
}, [filteredProducts]);

Y quizás un tercer efecto gestiona la bandera de estado vacío:

useEffect(() => {
  setHasResults(resultCount > 0);
}, [resultCount]);

Juntos forman un pequeño pipeline interno:

products/search/category
        ↓
filteredProducts
        ↓
resultCount
        ↓
hasResults

Ninguno de estos pasos interactúa con nada fuera de React. Solo transforman valores que React ya posee, y esa distinción es la clave del asunto. La documentación de React trata a los Effects como una vía de escape para mantener un componente al día con elementos que React no controla, como una API del navegador, un socket o un widget que no sea de React. Cuando un Effect existe únicamente para establecer un valor del estado del componente en respuesta a otro, la guía actual recomienda preguntarse si ese segundo valor de estado debería existir en absoluto.

También existe un costo en tiempo de ejecución que es fácil pasar por alto. Cada efecto se ejecuta después de que React ya ha completado el renderizado, por lo que cada eslabón de la cadena provoca otro renderizado con valores parcialmente actualizados. De ahí proviene ese breve destello de “no hay resultados” en el escenario inicial: durante un renderizado, la nueva lista existe pero la bandera sigue reflejando el conteo anterior.

La versión calculada no tiene cadena alguna:

const filteredProducts = filterProducts(
  products,
  search,
  category
);

const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;

Se trata de algo más que una sintaxis más ordenada: cambia la forma en que hay que pensar. Con el estado derivado almacenado, se controla cuándo se escribió por última vez cada valor, si el Effect correspondiente ya se ejecutó, si su array de dependencias está completo y si hay otra actualización en cola detrás de ella. Con un cálculo, uno solo piensa en entradas y salidas, y para transformaciones puras este es un modelo mucho más fácil de mantener. Si su código ya contiene Effects de este tipo, la refactorización paso a paso en Dejar de sincronizar el estado con useEffect explica cómo eliminarlos de forma segura.

Sustituir banderas booleanas por un único estado

Los valores duplicados son una forma de exceso de estado. Otra ocurre cuando un mismo concepto se distribuye en varios booleanos independientes. La presentación de un formulario es el caso clásico:

const [isIdle, setIsIdle] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

El flujo debe encontrarse exactamente en una de las cuatro fases:

idle
submitting
success
error

No obstante, cuatro booleanos pueden expresar dieciséis combinaciones, y muchas de ellas no tienen sentido. El formulario puede afirmar al mismo tiempo que se está enviando y que ya ha tenido éxito:

isSubmitting = true
isSuccess = true

O bien puede informar simultáneamente sobre éxito y fracaso:

isSuccess = true
isError = true

O bien cada bandera puede ser false, lo que no corresponde a ninguna de las fases. La interfaz de usuario puede evitar intencionalmente estas combinaciones, pero la estructura de datos las permite; por lo tanto, una llamada al setter omitida en un manejador es suficiente para que se produzcan. La guía de React sobre la estructuración del estado recomienda explícitamente evitar contradicciones como esta y reducir las variables que permiten expresar estados de interfaz imposibles.

Un único valor de estado describe el concepto con mucha más precisión. En TypeScript, una unión de literales de cadena también permite al compilador rechazar errores tipográficos y fases desconocidas:

type Status =
  | "idle"
  | "submitting"
  | "success"
  | "error";

const [status, setStatus] = useState<Status>("idle");

Los booleanos prácticos siguen estando disponibles, ahora como valores derivados:

const isSubmitting = status === "submitting";
const isSuccess = status === "success";
const isError = status === "error";

La diferencia parece menor, pero es fundamental. La primera versión exige que su código mantenga cuatro factores en concordancia. La segunda almacena un hecho y muestra cuatro perspectivas de él.

Los beneficios aumentan a medida que crece el componente. Un proceso de pago podría pasar por estas fases:

editing
validating
submitting
confirmed
failed

Un importador de archivos podría pasar por estas:

idle
uploading
processing
completed
failed

Cuando un componente tenga modos que se excluyen entre sí, haga que esa exclusión forme parte del modelo de estado en lugar de ser una regla que cada manejador deba respetar. Usted decide qué estados puede representar el programa, y esa decisión merece tanta atención como el propio marcado. Una advertencia: si una fase contiene datos, como un mensaje de error que solo existe en la fase failed, una unión discriminada de objetos mantiene esos datos asociados a la fase correcta en lugar de agregar otra variable suelta.

Menos variables no es lo mismo que un único objeto grande

Cuando un equipo escucha “reducir el estado”, una corrección excesiva tentadora es meterlo todo en un único objeto:

const [state, setState] = useState({
  search: "",
  category: "all",
  selectedProductId: null,
  sidebarOpen: false,
  page: 1,
});

Eso no constituye una mejora por defecto. Las llamadas separadas a useState no conllevan ningún costo significativo, por lo que minimizar su número no es un objetivo. El objetivo es representar la información de manera clara e independiente y evitar almacenar dos veces la misma verdad.

search y category cambian según sus propios horarios, y sidebarOpen no tiene nada que ver con ninguno de ellos. Mantenerlos como variables separadas hace que cada actualización sea evidente en el lugar donde se realiza:

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [selectedProductId, setSelectedProductId] =
  useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);

La documentación de React sostiene lo mismo. Los valores que siempre cambian juntos pueden justificar su agrupación, mientras que los datos redundantes, contradictorios, duplicados o profundamente anidados deben reducirse. Fusionar valores no relacionados también tiene una desventaja práctica: cada actualización debe propagar el objeto anterior, y olvidarse de hacerlo elimina silenciosamente los demás campos.

Por lo tanto, la pregunta relevante no es si un conjunto de valores puede caber en un único objeto. Casi cualquier cosa puede hacerlo. En su lugar, pregúntese:

¿Estos valores forman un estado coherente cuyas transiciones están relacionadas entre sí?

Cuando así es, agruparlos puede hacer que el código sea más claro. Cuando no lo son, un objeto combinado solo dificulta saber qué actualización afecta a qué. Reducir el estado consiste en eliminar conocimientos almacenados dos veces, no en reducir al componente a la menor cantidad posible de Hooks.

Obtener valores sin ignorar el rendimiento

Sacar una lista filtrada del estado suele generar una objeción: ¿el filtro no se ejecutará ahora en cada renderizado? Sí lo hace, y para la mayoría de las transformaciones cotidianas ese es exactamente el equilibrio adecuado. Filtrar un array de tamaño moderado es económico, y calcularlo directamente mantiene el componente simple sin ningún costo apreciable.

Si el análisis muestra que una transformación es realmente costosa, por ejemplo una lista grande que se filtra y ordena, puedes almacenar en caché el resultado con useMemo:

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

       const matchesCategory =
        category === "all" ||
        product.category === category;
      return matchesSearch && matchesCategory;
    })
    .sort(compareProducts);
}, [products, search, category, sortBy]);

Presta atención a lo que useMemo no cambia. filteredProducts ya no es un estado modificable; sigue siendo una función pura de sus entradas. La memorización solo decide si React puede reutilizar el resultado anterior en una renderización determinada en lugar de volver a calcularlo. Esto mantiene la corrección y la optimización como aspectos separados. La documentación de React presenta useMemo estrictamente como una optimización de rendimiento y advierte contra depender de ella para garantizar un comportamiento correcto, ya que React podría descartar los valores almacenados en caché.

El orden de las operaciones que resulta de esto es:

First make the state model correct.

Then measure.

Then optimize expensive calculations if necessary.

Usar el estado como una caché implementada manualmente invierte ese orden. Añade complejidad de sincronización desde el principio, antes incluso de que se demuestre que el cálculo es lento. También vale la pena verificar que un cálculo memorizado incluya todas las entradas en su array de dependencias; en el ejemplo anterior, sortBy se incluye porque se espera que el comparador de ordenamiento dependa de él.

Pon cada elemento de estado donde se comparta la decisión

Incluso el estado que realmente necesita existir puede causar problemas si se encuentra en el componente incorrecto. Supongamos que cada fila de producto registra su propia selección:

function ProductRow({ product }) {
  const [selected, setSelected] = useState(false);

  // ...
}

Eso funciona siempre y cuando cada fila pueda activarse o desactivarse de forma independiente. Ahora cambia el requisito: solo se puede seleccionar un producto a la vez. De repente, varios componentes hermanos albergan su propia copia de lo que debería ser un dato compartido único, es decir, qué producto está seleccionado. Cuando esa información es relevante para varios componentes hermanos, su componente padre debe ser quien la controle:

function ProductTable({ products }) {
  const [selectedProductId, setSelectedProductId] =
    useState<string | null>(null);

   return products.map((product) => (
    <ProductRow
      key={product.id}
      product={product}
      selected={product.id === selectedProductId}
      onSelect={() => setSelectedProductId(product.id)}
    />
  ));
}

Las filas ya no almacenan la selección en absoluto. Reciben un valor booleano y una función de callback, y existe exactamente una fuente única de verdad:

selectedProductId

La documentación de React presenta esto como la asignación a cada pieza distinta de estado de un único componente responsable. Cuando varios componentes deben coordinarse en torno a la misma información, elevarla al padre compartido más cercano evita que las copias diverjan.

Nada de esto justifica colocar todo en la raíz de la aplicación. Debe quedar claro que cualquier otro dato necesario debe permanecer local. El hecho de que una herramienta de ayuda esté visible no tiene por qué estar junto a los datos de autenticación, y un campo de formulario parcialmente llenado rara vez justifica el uso de un almacenamiento global. El estado es más fácil de gestionar cuando su propietario coincide con el grado de difusión de la decisión subyacente. Si se coloca en un nivel demasiado bajo, los componentes duplican la información; si se coloca en un nivel demasiado alto, las partes alejadas de la aplicación comienzan a volver a renderizarse por cambios que no les afectan. Encontrar ese límite es una parte importante de un buen diseño de estado, y Rethinking React State: Where Your Data Should Actually Live profundiza en las opciones de almacenamiento local, compartido, en el servidor y a través de URLs.

Los reducers organizan las transiciones, no el modelo

Cuando un componente recopila muchos métodos de configuración, pasar a useReducer es un paso común y, a menudo, adecuado. En lugar de un manejador que realice varias llamadas coordinadas como estas:

setStatus("submitting");
setError(null);
setLastAttempt(Date.now());

se describe lo que ocurrió como un único evento:

dispatch({ type: "submitted" });

Un reductor agrupa las transiciones en un solo lugar, lo cual resulta útil cuando varios valores realmente relacionados cambian al mismo tiempo. Lo que no puede hacer es eliminar la redundancia de los datos. Este estado inicial sigue siendo problemático:

const initialState = {
  search: "",
  products: [],
  filteredProducts: [],
  resultCount: 0,
  hasResults: true,
};

Al incluir valores duplicados en un reductor, el problema de sincronización permanece intacto; simplemente se traslada la lógica de sincronización al reductor. Cada acción podría actualizar correctamente todas las copias hoy, pero el modelo sigue permitiendo varias versiones almacenadas de la misma información, y la próxima acción que se agregue podría pasar por alto alguna de ellas.

Un reductor mejor solo mantiene las entradas:

const initialState = {
  search: "",
  category: "all",
  sortBy: "name",
};

La lista de productos visible se genera luego durante la renderización a partir del estado del reductor más los products actuales. useReducer es una buena herramienta cuando las transiciones se vuelven complicadas, pero no reemplaza la pregunta más básica de qué es lo que el componente realmente necesita recordar. Responde a eso primero y luego elige la herramienta que lo gestione.

Una lista de verificación para revisar el estado del componente

useState hace que agregar estado sea casi sin fricción, y esa facilidad oculta el costo arquitectónico. Cada nueva variable es otro valor que puede cambiar por sí mismo. Si duplica algo ya disponible, ahora se necesitan reglas para mantener alineadas ambas versiones. Un duplicado es manejable. Cinco de ellos generan un enredo de efectos y listas de dependencias, setters que activan otros setters, código de reinicio, lecturas obsoletas, banderas en conflicto y errores que solo aparecen después de una secuencia específica de clics. La solución rara vez es un mecanismo de sincronización más inteligente; por lo general, la sincronización no debería existir desde un principio.

Cuando el estado de un componente sigue creciendo, revisa cada valor almacenado y pregúntate:

  • ¿Representa una decisión tomada por el usuario o por el sistema?
  • ¿Necesita el componente recordarlo entre renders?
  • ¿Podría reconstruirse con precisión a partir de los props actuales o del resto del estado?
  • ¿Existe alguna secuencia de eventos en la que difiera de otro valor almacenado?
  • ¿Algún otro componente posee una copia del mismo dato?
  • ¿Le pertenece al componente cuyo subárbol comparte realmente esa decisión?
  • Estas preguntas le brindan mucha más información que un simple recuento de Hooks. Un componente que almacena ocho elementos de estado independientes y necesarios puede estar perfectamente diseñado, mientras que uno con tres variables ya tiene demasiadas si dos de ellas son copias o consecuencias de la tercera.

    Puntos clave

    • Almacene las causas, como las entradas y selecciones del usuario; obtenga las consecuencias, como listas filtradas, conteos y flags, durante el renderizado.
    • Un Effect que solo establece estado a partir de otro estado es una señal de que ese segundo valor debería ser el resultado de un cálculo.
    • Modela los modos mutuamente excluyentes como un único valor de estado para que no se puedan representar combinaciones imposibles.
    • Grupa los valores solo cuando cambian juntos; un objeto grande en sí mismo no constituye un objetivo.
    • Utiliza useMemo después de realizar mediciones, y recuerda que se trata de un caché, no de una fuente de verdad.
    • Asigna un único propietario a los datos compartidos en el ancestro común más bajo, y mantén el estado de la interfaz únicamente local.
    • Cuando un componente se vuelva difícil de modificar, pregúntate qué hecho del mundo real representa cada variable de estado antes de agregar otro setter. El estado que es más fácil de mantener sincronizado es aquel que nunca se almacenó.

    Lecturas relacionadas

  • Cómo se compunden las pequeñas decisiones en codificaciones de React de larga vida — Quince hábitos de mantenimiento para aplicaciones React que funcionan durante años: código legible, componentes enfocados, estado limitado, dependencias esenciales, pruebas y monitoreo.
  • Elegir la herramienta correcta de React: Derive, Handle, Fetch, Defer o Effect — Una guía de decisiones para reemplazar las llamadas reflexivas a useEffect por valores derivados, manejadores de eventos, una capa de datos, useTransition, useMemo medido y las API de React 19.
  • Diseñando para solicitudes fallidas en Angular: Estados, interceptores y reintentos — Aprenda cómo clasificar los fallos HTTP en Angular, limpiar el estado de carga, centralizar el manejo en interceptores, reintentar de forma segura y mostrar mensajes con los que los usuarios puedan actuar.
  • Una lista de verificación para la revisión de código en React: Estado derivado, reducidos, Effects y Memo — Aprenda a identificar siete problemas comunes en las revisiones de código en React, desde estado duplicado y obtención de datos manual hasta uso incorrecto de Effects y memorización excesiva, y qué escribir en su lugar.