Gestión de estado en React Native con hooks y arquitectura

Capítulo 4

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

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: isLoading de 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?

PreguntaSi la respuesta es “sí”Recomendación
¿Solo lo usa un componente y no afecta a otros?Estado es efímero y de UIuseState local
¿Debe sobrevivir al cambio de pantallas o ser compartido por varias ramas?Se comparte o se necesita persistencia en memoriaContexto (o store) con estado de dominio
¿Tiene transiciones complejas (varias acciones) o reglas?Hay múltiples eventos y estados intermediosuseReducer (local o global)
¿Proviene del backend y requiere cache/refresh?Hay sincronización con servidorTrátalo como estado remoto (capa de datos + cache)
¿Cambios muy frecuentes afectan rendimiento si es global?Re-render masivo posibleManté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:

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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 children o 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 → useState en 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 useMemo o useCallback.
  • 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/error remotos dentro del mismo reducer del dominio si no es necesario.
  • Define límites: el dominio consume “repositorios” o funciones de datos, no fetch directo 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);
}

Ahora responde el ejercicio sobre el contenido:

En una app de React Native, ¿qué criterio indica mejor que un estado debería promoverse de local a global (por ejemplo, a un Context/store) en lugar de mantenerse con useState en un solo componente?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Se recomienda empezar con estado local y promoverlo a global solo cuando existe una razón clara: compartirlo entre pantallas/ramas o necesitar persistencia en memoria. Estados efímeros de UI suelen quedarse locales, y estados muy frecuentes globales pueden causar re-renders masivos.

Siguiente capítulo

Consumo de APIs y manejo de datos asíncronos en React Native

Arrow Right Icon
Portada de libro electrónico gratuitaReact Native desde Cero a App Profesional
33%

React Native desde Cero a App Profesional

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.