Mapa mental del estado: UI, dominio y remoto
En una app real, “estado” no es una sola cosa. Para tomar buenas decisiones, separa explícitamente tres categorías:
- Estado de UI: controla la presentación y la interacción inmediata. Ejemplos:
isLoadingde un botón,isModalOpen,activeTab, texto temporal de un input. - Estado de dominio: representa reglas y entidades del negocio en memoria. Ejemplos: carrito, sesión del usuario, preferencias, selección actual de un elemento, borradores de un formulario con validación.
- Estado remoto: datos que vienen del servidor y su ciclo de vida (fetch, cache, reintentos, paginación, invalidación). Ejemplos: lista de productos, detalle de un pedido, resultados de búsqueda.
Esta separación reduce acoplamiento: la UI no “posee” el dominio, y el dominio no se mezcla con detalles de red.
Criterios: ¿estado local o global?
| Pregunta | Si la respuesta es “sí” | Recomendación |
|---|---|---|
| ¿Solo lo usa un componente y no afecta a otros? | Estado es efímero y de UI | useState local |
| ¿Debe sobrevivir al cambio de pantallas o ser compartido por varias ramas? | Se comparte o se necesita persistencia en memoria | Contexto (o store) con estado de dominio |
| ¿Tiene transiciones complejas (varias acciones) o reglas? | Hay múltiples eventos y estados intermedios | useReducer (local o global) |
| ¿Proviene del backend y requiere cache/refresh? | Hay sincronización con servidor | Trátalo como estado remoto (capa de datos + cache) |
| ¿Cambios muy frecuentes afectan rendimiento si es global? | Re-render masivo posible | Mantén local y deriva lo mínimo global |
Regla práctica: empieza local. Solo promueve a global cuando exista una razón clara (compartición real, consistencia transversal o necesidad de orquestación).
useState: estado de UI simple y derivación
useState es ideal para UI simple. Evita duplicar estado: si puedes derivarlo de otro, no lo guardes.
import { useMemo, useState } from 'react';
function SearchHeader({ items }) {
const [query, setQuery] = useState('');
// Derivado: no guardes filteredItems en estado
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(i => i.name.toLowerCase().includes(q));
}, [items, query]);
return (
<>
<TextInput value={query} onChangeText={setQuery} placeholder="Buscar" />
<List data={filtered} />
</>
);
}Consejo: si el estado depende del anterior, usa la forma funcional:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
setCount(prev => prev + 1);useReducer: transiciones explícitas y escalables
Cuando el estado tiene varias propiedades relacionadas y múltiples eventos (cargar, éxito, error, reset, actualizar un campo), useReducer aporta claridad. Piensa en “acciones” y “transiciones”.
Ejemplo: máquina simple para carga remota (sin librerías)
import { useEffect, useReducer } from 'react';
const initialState = {
status: 'idle', // 'loading' | 'success' | 'error'
data: null,
error: null,
};
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR':
return { ...state, status: 'error', error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
}
export function useRemoteResource(load) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
let cancelled = false;
dispatch({ type: 'FETCH_START' });
load()
.then((data) => {
if (!cancelled) dispatch({ type: 'FETCH_SUCCESS', payload: data });
})
.catch((err) => {
if (!cancelled) dispatch({ type: 'FETCH_ERROR', payload: err });
});
return () => {
cancelled = true;
};
}, [load]);
return state;
}Aunque este patrón no reemplaza un sistema de cache, te obliga a modelar estados intermedios y evita “booleanos sueltos” (isLoading, hasError) inconsistentes.
Evitar prop drilling: composición, children y contextos
El prop drilling aparece cuando pasas props por muchos niveles solo para llegar a un componente profundo. Antes de crear un contexto global, prueba:
- Composición: pasa componentes como
childreno render props. - Co-locación: mueve el estado al ancestro más cercano que realmente lo necesita.
- Contexto por responsabilidad: cuando el dato es transversal, crea un contexto pequeño y específico.
Patrón: “Provider + hook” por responsabilidad
Diseña contextos que representen una responsabilidad (sesión, carrito, preferencias), no un “AppContext” gigante. Esto reduce re-renders y acoplamiento.
import React, { createContext, useContext, useMemo, useReducer } from 'react';
const CartStateContext = createContext(null);
const CartActionsContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const item = action.payload;
const prevQty = state.quantities[item.id] ?? 0;
return {
...state,
quantities: { ...state.quantities, [item.id]: prevQty + 1 },
itemsById: { ...state.itemsById, [item.id]: item },
};
}
case 'REMOVE_ITEM': {
const id = action.payload;
const prevQty = state.quantities[id] ?? 0;
const nextQty = Math.max(0, prevQty - 1);
const nextQuantities = { ...state.quantities, [id]: nextQty };
return { ...state, quantities: nextQuantities };
}
default:
return state;
}
}
const initialCart = { itemsById: {}, quantities: {} };
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialCart);
const actions = useMemo(() => ({
addItem: (item) => dispatch({ type: 'ADD_ITEM', payload: item }),
removeItem: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
}), []);
return (
<CartStateContext.Provider value={state}>
<CartActionsContext.Provider value={actions}>
{children}
</CartActionsContext.Provider>
</CartStateContext.Provider>
);
}
export function useCartState() {
const ctx = useContext(CartStateContext);
if (!ctx) throw new Error('useCartState debe usarse dentro de CartProvider');
return ctx;
}
export function useCartActions() {
const ctx = useContext(CartActionsContext);
if (!ctx) throw new Error('useCartActions debe usarse dentro de CartProvider');
return ctx;
}Separar StateContext y ActionsContext ayuda a que componentes que solo disparan acciones no re-rendericen por cambios de estado.
Separación por capas: UI vs dominio vs remoto
Una arquitectura simple y efectiva en React Native es:
- UI (presentación): componentes que renderizan y emiten eventos.
- Dominio (estado + reglas): reducers, casos de uso, validaciones, selectores.
- Datos remotos: funciones de API, mapeo DTO→modelo, cache/almacenamiento.
Objetivo: que un componente no conozca detalles de endpoints ni estructuras crudas del backend.
Ejemplo de modelado: DTO a modelo de dominio
// DTO (lo que viene del backend)
// { id: string, title: string, price_cents: number }
function mapProduct(dto) {
return {
id: dto.id,
title: dto.title,
price: dto.price_cents / 100,
};
}Esto evita que tu UI dependa de price_cents o nombres inconsistentes. Si el backend cambia, ajustas el mapeo, no toda la app.
Guía práctica paso a paso: diseñar una estrategia de estado
Paso 1: lista tus “fuentes de verdad”
- ¿Qué datos son remotos? (listas, detalles, resultados)
- ¿Qué datos son de dominio? (carrito, sesión, preferencias)
- ¿Qué datos son de UI? (modales, inputs, toggles)
Paso 2: define el lugar de cada estado
- UI efímera →
useStateen el componente. - Dominio compartido →
useReducer+ Context por responsabilidad. - Remoto → capa de datos + hook de consulta (aunque sea casero), evitando mezclarlo con UI.
Paso 3: define acciones y selectores
En dominio, evita que la UI “toque” estructuras internas complejas. Expón:
- Acciones: funciones como
addItem,logout,setPreference. - Selectores: funciones para derivar datos: total del carrito, items ordenados, etc.
export function selectCartTotal(state) {
return Object.entries(state.quantities).reduce((sum, [id, qty]) => {
const item = state.itemsById[id];
if (!item) return sum;
return sum + item.price * qty;
}, 0);
}Paso 4: minimiza re-renders
- Divide contextos (estado/acciones, o por subdominios).
- Memoiza acciones con
useMemoouseCallback. - Evita guardar derivados en estado.
Manejo de formularios sin acoplamiento
Un error común es mezclar validación, estado de inputs, envío remoto y navegación en el mismo componente. Separa:
- Estado del formulario (valores, touched, errores) → reducer/hook local.
- Reglas de validación → funciones puras.
- Envío → función/servicio inyectable.
Patrón: useReducer para formulario + validación pura
import { useMemo, useReducer } from 'react';
function validate(values) {
const errors = {};
if (!values.email.includes('@')) errors.email = 'Email inválido';
if (values.password.length < 8) errors.password = 'Mínimo 8 caracteres';
return errors;
}
const initial = {
values: { email: '', password: '' },
touched: { email: false, password: false },
};
function reducer(state, action) {
switch (action.type) {
case 'CHANGE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
};
case 'BLUR':
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case 'RESET':
return initial;
default:
return state;
}
}
export function useLoginForm() {
const [state, dispatch] = useReducer(reducer, initial);
const errors = useMemo(() => validate(state.values), [state.values]);
const isValid = Object.keys(errors).length === 0;
return {
values: state.values,
touched: state.touched,
errors,
isValid,
onChange: (field) => (value) => dispatch({ type: 'CHANGE', field, value }),
onBlur: (field) => () => dispatch({ type: 'BLUR', field }),
reset: () => dispatch({ type: 'RESET' }),
};
}La UI solo consume el hook:
function LoginScreen({ submitLogin }) {
const form = useLoginForm();
const onSubmit = async () => {
if (!form.isValid) return;
await submitLogin(form.values); // inyectado, no acoplado
};
return (
<>
<TextInput value={form.values.email} onChangeText={form.onChange('email')} onBlur={form.onBlur('email')} />
{form.touched.email && form.errors.email ? <Text>{form.errors.email}</Text> : null}
<TextInput value={form.values.password} onChangeText={form.onChange('password')} onBlur={form.onBlur('password')} secureTextEntry />
{form.touched.password && form.errors.password ? <Text>{form.errors.password}</Text> : null}
<Button title="Entrar" onPress={onSubmit} disabled={!form.isValid} />
</>
);
}Ventaja: puedes testear validate y el reducer sin renderizar componentes.
Modelado de datos para listas, filtros y selección
Las listas suelen mezclar: datos, paginación, filtros, orden, selección y estados de carga. Para mantenerlo controlado, modela con estructuras predecibles.
Normalización: byId + allIds
Evita duplicación y facilita actualizaciones puntuales.
const state = {
products: {
byId: {
'p1': { id: 'p1', title: 'A', price: 10 },
'p2': { id: 'p2', title: 'B', price: 12 },
},
allIds: ['p1', 'p2'],
},
};Filtros y orden: estado mínimo + derivación
Guarda solo la intención del usuario (query, categoría, orden), y deriva la lista visible con un selector memoizable.
const ui = {
filters: { query: '', categoryId: null, sort: 'PRICE_ASC' },
selection: { selectedProductId: null },
};
function selectVisibleProducts(domainState) {
const { byId, allIds } = domainState.products;
const { query, categoryId, sort } = domainState.ui.filters;
let items = allIds.map((id) => byId[id]).filter(Boolean);
const q = query.trim().toLowerCase();
if (q) items = items.filter((p) => p.title.toLowerCase().includes(q));
if (categoryId) items = items.filter((p) => p.categoryId === categoryId);
if (sort === 'PRICE_ASC') items = [...items].sort((a, b) => a.price - b.price);
if (sort === 'PRICE_DESC') items = [...items].sort((a, b) => b.price - a.price);
return items;
}Selección: guarda IDs, no objetos
Para selección de elementos (en lista o detalle), guarda selectedId y resuelve el objeto desde byId. Esto evita referencias obsoletas.
function selectSelectedProduct(state) {
const id = state.ui.selection.selectedProductId;
return id ? state.products.byId[id] : null;
}Contextos: límites, composición y anti-patrones
Anti-patrón: “AppContext” para todo
Un contexto único con sesión, tema, carrito, flags de UI y resultados remotos produce:
- Re-renders amplios.
- Dificultad para testear.
- Dependencias cruzadas.
Patrón recomendado: múltiples providers pequeños
function AppProviders({ children }) {
return (
<SessionProvider>
<PreferencesProvider>
<CartProvider>
{children}
</CartProvider>
</PreferencesProvider>
</SessionProvider>
);
}Si te preocupa el “provider hell”, encapsúlalo como arriba para mantener el árbol limpio.
Estado remoto: reglas para no mezclarlo con dominio
Incluso si implementas el fetch manualmente, aplica estas reglas:
- No guardes respuestas crudas del backend en el estado de UI. Mapea a modelos.
- No mezcles
isLoading/errorremotos dentro del mismo reducer del dominio si no es necesario. - Define límites: el dominio consume “repositorios” o funciones de datos, no
fetchdirecto desde componentes.
Ejemplo: repositorio simple + hook de caso de uso
// data/productsRepository.js
export function createProductsRepository(apiClient) {
return {
async list() {
const dtos = await apiClient.get('/products');
return dtos.map(mapProduct);
},
};
}
// domain/useListProducts.js
import { useCallback } from 'react';
import { useRemoteResource } from '../shared/useRemoteResource';
export function useListProducts(repo) {
const load = useCallback(() => repo.list(), [repo]);
return useRemoteResource(load);
}