Gestión de estado en React simplificada: separa el estado del servidor del estado del cliente, sigue unas reglas y detecta pronto los signos de complejidad creciente.

El estado es cualquier dato que puede cambiar mientras tu app se ejecuta. Eso incluye lo que ves (un modal abierto), lo que estás editando (un borrador de formulario) y los datos que obtienes (una lista de proyectos). El problema es que todo eso se llama "estado", aunque se comporta de formas muy distintas.
La mayoría de las apps desordenadas fallan de la misma manera: demasiados tipos de estado se mezclan en el mismo sitio. Un componente acaba teniendo datos del servidor, flags de UI, borradores de formulario y valores derivados, y trata de mantenerlos alineados con efectos. Pronto ya no puedes responder preguntas sencillas como "¿de dónde viene este valor?" o "¿qué lo actualiza?" sin buscar en varios archivos.
Las apps React generadas se desvían hacia esto más rápido porque es fácil aceptar la primera versión que funciona. Añades una nueva pantalla, copias un patrón, parcheas un bug con otro useEffect, y ahora tienes dos fuentes de verdad. Si el generador o el equipo cambian de dirección a mitad de camino (estado local aquí, store global allá), la base de código acumula patrones en lugar de construirse sobre uno solo.
El objetivo es aburrido: menos tipos de estado y menos lugares donde mirar. Cuando existe un hogar obvio para los datos del servidor y uno obvio para el estado solo de UI, los bugs se hacen más pequeños y los cambios dejan de parecer riesgosos.
"Mantenlo sencillo" significa seguir unas pocas reglas:
Un ejemplo concreto: si una lista de usuarios viene del backend, trátala como estado del servidor y obténla donde se usa. Si selectedUserId existe solo para controlar un panel de detalles, mantenlo como estado UI cerca de ese panel. Mezclar esos dos es donde empieza la complejidad.
La mayoría de los problemas de estado en React comienzan con una confusión: tratar datos del servidor como estado de UI. Sepáralos desde el principio y la gestión de estado se mantiene tranquila, incluso cuando tu app crece.
El estado del servidor pertenece al backend: usuarios, órdenes, tareas, permisos, precios, feature flags. Puede cambiar sin que tu app haga nada (otra pestaña lo actualiza, un admin lo edita, un job corre, los datos expiran). Como es compartido y cambiante, necesitas fetching, caching, refetching y manejo de errores.
El estado del cliente es lo que solo le importa a tu UI ahora mismo: qué modal está abierto, qué pestaña está seleccionada, un toggle de filtro, orden de sort, una barra lateral colapsada, un borrador de búsqueda. Si cierras la pestaña, está bien perderlo.
Una prueba rápida es: "¿Podría refrescar la página y reconstruir esto desde el servidor?"
También existe el estado derivado, que te evita crear estado adicional. Es un valor que puedes calcular a partir de otros valores, así que no lo almacenas. Listas filtradas, totales, isFormValid y "mostrar estado vacío" suelen pertenecer aquí.
Ejemplo: obtienes una lista de proyectos (estado del servidor). El filtro seleccionado y el flag de diálogo "Nuevo proyecto" son estado del cliente. La lista visible tras filtrar es estado derivado. Si guardas la lista visible por separado, se desincronizará y perseguirás bugs tipo "¿por qué está obsoleta?".
Esta separación ayuda cuando una herramienta como Koder.ai genera pantallas rápidamente: mantén los datos del backend en una capa de fetching, mantiene las elecciones de UI cerca de los componentes y evita almacenar valores computados.
El estado se vuelve doloroso cuando un dato tiene dos dueños. La forma más rápida de mantenerlo simple es decidir quién posee qué y apegarse a ello.
Ejemplo: obtienes una lista de usuarios y muestras detalles cuando uno está seleccionado. Un error común es almacenar el objeto completo del usuario seleccionado en el estado. Guarda selectedUserId en su lugar. Mantén la lista en la cache del servidor. La vista de detalles busca el usuario por ID, así que los refetches actualizan la UI sin código extra de sincronización.
En apps React generadas, también es fácil aceptar estado "útil" generado que duplica datos del servidor. Cuando veas código que hace fetch -> setState -> edit -> refetch, pausa. Eso suele ser una señal de que estás construyendo una segunda base de datos en el navegador.
El estado del servidor es cualquier cosa que vive en el backend: listas, páginas de detalle, resultados de búsqueda, permisos, contadores. El enfoque aburrido es elegir una herramienta para ello y apegarse a ella. Para muchas apps React, TanStack Query es suficiente.
El objetivo es sencillo: los componentes piden datos, muestran loading y errores, y no se preocupan por cuántas llamadas fetch suceden bajo el capó. Esto importa en apps generadas porque pequeñas inconsistencias se multiplican rápido al añadir nuevas pantallas.
Trata las claves de consulta como un sistema de nombres, no como una ocurrencia posterior. Mantenlas consistentes: claves en arrays estables, solo incluye entradas que cambian el resultado (filtros, página, orden), y prefiere unas pocas formas predecibles en lugar de muchos casos únicos. Muchos equipos también ponen la construcción de claves en pequeños helpers para que cada pantalla use las mismas reglas.
Para escrituras, usa mutations con manejo explícito en el success. Una mutation debe responder dos preguntas: ¿qué cambió? y ¿qué debe hacer la UI después?
Ejemplo: creas una tarea nueva. En el success, o invalidas la query de lista de tareas (para que recargue una vez) o haces una actualización dirigida en la cache (añadir la tarea nueva a la lista cacheada). Elige una aproximación por característica y mantenla consistente.
Si te sientes tentado a añadir llamadas de refetch en varios lugares "por si acaso", elige un movimiento aburrido en su lugar:
El estado cliente es lo que el navegador posee: un flag de sidebar abierto, una fila seleccionada, texto de filtro, un borrador antes de guardar. Mantenlo cerca de donde se usa y por lo general se mantiene manejable.
Empieza pequeño: useState en el componente más cercano. Cuando generas pantallas (por ejemplo con Koder.ai), es tentador empujar todo a una store global "por si acaso". Ahí es donde terminas con una store que nadie entiende.
Solo mueve estado hacia arriba cuando puedas nombrar el problema de compartición.
Ejemplo: una tabla con un panel de detalles puede mantener selectedRowId en el componente tabla. Si una barra de herramientas en otra parte de la página también lo necesita, súbelo al componente de la página. Si una ruta separada (como edición masiva) lo necesita, ahí tiene sentido una pequeña store.
Si usas una store (Zustand o similar), mantenla enfocada en un trabajo. Almacena el "qué" (IDs seleccionados, filtros), no los "resultados" (listas ordenadas) que puedes derivar.
Cuando una store empieza a crecer, pregúntate: ¿sigue siendo una sola feature? Si la respuesta honesta es "más o menos", sepárala ahora, antes de que la siguiente característica la convierta en una bola de estado que da miedo tocar.
Los bugs de formularios suelen venir de mezclar tres cosas: lo que el usuario escribe, lo que el servidor ha guardado y lo que la UI muestra.
Para una gestión aburrida del estado, trata el formulario como estado cliente hasta que lo envíes. Los datos del servidor son la última versión guardada. El formulario es un borrador. No edites el objeto del servidor in situ. Copia valores en el estado borrador, deja que el usuario los cambie libremente, luego envía y refetch (o actualiza la cache) en success.
Decide pronto qué debe persistir cuando el usuario navega fuera. Esa única elección evita muchos bugs sorpresa. Por ejemplo, el modo de edición inline y dropdowns abiertos deberían generalmente resetear, mientras que un borrador largo de un asistente o un mensaje no enviado podría persistir. Persiste tras reload solo cuando los usuarios lo esperan claramente (como un formulario de checkout).
Mantén las reglas de validación en un solo lugar. Si las repartes entre inputs, handlers de submit y helpers, acabarás con errores descoordinados. Prefiere un solo esquema (o una función validate()), y deja que la UI decida cuándo mostrar errores (on change, on blur o on submit).
Ejemplo: generas una pantalla Edit Profile en Koder.ai. Carga el perfil guardado como estado del servidor. Crea estado borrador para los campos del formulario. Muestra "cambios sin guardar" comparando borrador vs guardado. Si el usuario cancela, descarta el borrador y muestra la versión del servidor. Si guarda, envía el borrador y luego reemplaza la versión guardada con la respuesta del servidor.
A medida que una app generada crece, es común acabar con los mismos datos en tres sitios: estado de componente, una store global y una cache. La solución normalmente no es una librería nueva. Es elegir un hogar por cada pieza de estado.
Un flujo de limpieza que funciona en la mayoría de apps:
filteredUsers si puedes calcularlo desde users + filter. Prefiere selectedUserId sobre un selectedUser duplicado.Ejemplo: una app CRUD generada con Koder.ai a menudo empieza con un useEffect fetch más una copia global de la misma lista. Tras centralizar el estado del servidor, la lista viene de una sola query y "refrescar" pasa a ser invalidación en lugar de sincronización manual.
Para nombres, mantenlo consistente y aburrido:
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.deleteEl objetivo es una fuente de verdad por cosa, con límites claros entre estado del servidor y estado del cliente.
Los problemas de estado empiezan pequeños, luego un día cambias un campo y tres partes de la UI no se ponen de acuerdo sobre el valor "real".
La señal más clara es datos duplicados: el mismo usuario o carrito vive en un componente, en una store global y en una cache de requests. Cada copia se actualiza en un momento distinto, y añades más código solo para mantenerlas iguales.
Otra señal es el código de sincronización: efectos que empujan estado de un lado a otro. Patrones como "cuando cambia la query, actualiza la store" y "cuando la store cambia, refetch" pueden funcionar hasta que un caso límite provoca valores obsoletos o bucles.
Algunas banderas rápidas:
needsRefresh, didInit, isSaving que nadie borra.Ejemplo: generas un dashboard en Koder.ai y añades un modal Edit Profile. Si los datos del perfil están en la cache de queries, copiados en una store global y duplicados en estado local de formulario, ahora tienes tres fuentes de verdad. La primera vez que añades refetching en segundo plano u optimistic updates, aparecen desajustes.
Cuando veas estas señales, el movimiento aburrido es escoger un dueño único para cada dato y eliminar los espejos.
Almacenar cosas "por si acaso" es una de las formas más rápidas de hacer el estado doloroso, especialmente en apps generadas.
Copiar respuestas de la API en una store global es una trampa común. Si los datos vienen del servidor (listas, detalles, perfil de usuario), no los copies por defecto en una store cliente. Elige un hogar para los datos del servidor (normalmente la cache de queries). Usa la store cliente para valores de UI que el servidor no conoce.
Almacenar valores derivados es otra trampa. Contadores, listas filtradas, totales, canSubmit e isEmpty normalmente deben calcularse a partir de entradas. Si el rendimiento se convierte en un problema, memoiza más tarde, pero no empieces guardando el resultado.
Un mega-store único para todo (auth, modales, toasts, filtros, borradores, flags de onboarding) se vuelve un vertedero. Divide por límites de feature. Si el estado se usa solo en una pantalla, mantenlo local.
Context es genial para valores estables (tema, user id actual, locale). Para valores que cambian rápido, puede causar re-renders amplios. Usa Context para wiring, y estado de componente (o una store pequeña) para valores de UI que cambian frecuentemente.
Finalmente, evita nombres inconsistentes. Claves de query casi idénticas y campos de store duplicados crean duplicación sutil. Elige un estándar simple y síguelo.
Cuando sientas la tentación de añadir "solo una variable de estado más", haz una verificación rápida de propiedad.
Primero, ¿puedes señalar un lugar donde suceden fetch y caching del servidor (una herramienta de queries, un conjunto de claves)? Si los mismos datos se obtienen en múltiples componentes y además se copian en una store, ya estás pagando intereses.
Segundo, ¿ese valor solo se necesita dentro de una pantalla (como "¿está abierto el panel de filtros?")? Si es así, no debería ser global.
Tercero, ¿puedes almacenar un ID en lugar de duplicar un objeto? Guarda selectedUserId y lee el usuario desde tu cache o lista.
Cuarto, ¿es derivado? Si puedes calcularlo desde el estado existente, no lo almacenes.
Finalmente, haz una prueba de trazado de un minuto. Si un compañero no puede responder "¿de dónde viene este valor?" (prop, estado local, cache del servidor, URL, store) en menos de un minuto, arregla la propiedad antes de añadir más estado.
Imagínate una app de administración generada (por ejemplo, una producida desde un prompt en Koder.ai) con tres pantallas: lista de clientes, página de detalle de cliente y un formulario de edición.
El estado se mantiene calmado cuando tiene hogares obvios:
La lista y las páginas de detalle leen estado del servidor desde la cache de queries. Cuando guardas, no vuelves a almacenar clientes en una store global. Envías la mutación y dejas que la cache se refresque o la actualices.
Para la pantalla de edición, mantén el borrador del formulario local. Inicialízalo desde el cliente obtenido, pero trátalo como separado una vez que el usuario empiece a escribir. Así, la vista de detalle puede refrescarse sin sobrescribir cambios a medio hacer.
La UI optimista es donde los equipos suelen duplicar todo. Normalmente no hace falta.
Cuando el usuario pulsa Guardar, actualiza solo el registro cliente cacheado y el elemento correspondiente de la lista, y revierte si la petición falla. Mantén el borrador en el formulario hasta que el guardado tenga éxito. Si falla, muestra un error y conserva el borrador para que el usuario reintente.
Supongamos que añades edición masiva y también necesita filas seleccionadas. Antes de crear una nueva store, pregúntate: ¿debe este estado sobrevivir a la navegación y al refresh?
Si sí, ponlo en la URL (IDs seleccionadas, filtros). Si no, mantenlo en el componente de la página. Si múltiples componentes distantes realmente lo necesitan al mismo tiempo (toolbar + tabla + footer), introduce una store compartida pequeña solo para ese estado cliente.
Las pantallas generadas pueden multiplicarse rápido, y eso es genial hasta que cada nueva pantalla trae sus propias decisiones de estado.
Escribe una nota corta para el equipo en el repo: qué cuenta como estado servidor, qué cuenta como estado cliente y qué herramienta posee cada uno. Manténlo lo suficientemente corto para que la gente realmente lo siga.
Añade un pequeño hábito en los pull requests: etiqueta cada nueva pieza de estado como servidor o cliente. Si es estado servidor, pregunta "¿dónde se carga, cómo se cachea y qué lo invalida?". Si es estado cliente, pregunta "¿quién lo posee y cuándo se resetea?".
Si estás usando Koder.ai (koder.ai), Planning Mode puede ayudarte a acordar los límites de estado antes de generar nuevas pantallas. Un snapshot y rollback te dan una forma segura de experimentar cuando un cambio de estado sale mal.
Elige una característica (como editar perfil), aplica las reglas de principio a fin y deja que eso sea el ejemplo que todos copien.
Empieza por etiquetar cada pieza de estado como servidor, cliente (UI) o derivado.
isValid).Una vez etiquetados, asegúrate de que cada elemento tenga un dueño obvio (cache de consultas, estado local del componente, URL o una pequeña store).
Usa esta prueba rápida: “¿Podría refrescar la página y reconstruir esto desde el servidor?”
Ejemplo: una lista de proyectos es estado del servidor; el ID de la fila seleccionada es estado del cliente.
Porque crea dos fuentes de verdad.
Si obtienes users y luego los copias en useState o en una store global, ahora tienes que mantenerlos sincronizados durante:
Regla por defecto: y crea estado local solo para preocupaciones puramente de UI o borradores.
Almacena valores derivados solo cuando realmente no puedes computarlos de forma barata.
Normalmente deberías calcularlos a partir de entradas existentes:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSavingSi el rendimiento se convierte en un problema real (medido), prefiere o estructuras de datos mejores antes de introducir más estado almacenado que pueda quedar obsoleto.
Por defecto: usa una herramienta de server-state (comúnmente TanStack Query) para que los componentes simplemente “pidan” datos y manejen loading/error.
Bases prácticas:
Mantenlo local hasta que puedas nombrar una necesidad real de compartir.
Regla de promoción:
Esto evita que tu store global se convierta en un vertedero de flags UI aleatorios.
Almacena IDs y flags pequeños, no objetos completos del servidor.
Ejemplo:
selectedUserIdselectedUser (objeto copiado)Luego renderiza detalles buscando el usuario en la lista/cache. Esto hace que los refetches en segundo plano y las actualizaciones funcionen correctamente sin código extra de sincronización.
Trata el formulario como un borrador (estado cliente) hasta que lo envíes.
Patrón práctico:
Esto evita editar datos del servidor “in situ” y pelear con refetches.
Señales comunes:
needsRefresh, didInit, isSaving).Estandariza la propiedad:
Si usas Koder.ai, usa Planning Mode para decidir la propiedad antes de generar pantallas y apóyate en snapshots/rollback si experimentas con cambios de estado.
useMemoEvita esparcir llamadas refetch() por todos lados “por si acaso”.
La solución suele ser eliminar duplicados y elegir un dueño por cada valor, no una librería nueva.