useReducer: Estado complejo simplificado

Lectura
20 min~9 min lectura
CONCEPTO CLAVE: useReducer es un hook de React que te permite manejar estado complejo mediante una función reductora (reducer). A diferencia de useState, que maneja cambios simples, useReducer es ideal cuando tienes múltiples sub-estados o acciones que dependen unas de otras, o cuando la lógica para actualizar el estado es compleja y predecible.

¿Qué problema resuelve useReducer?

Imaginemos que tenemos un componente de carrito de compras. Con useState, tendríamos algo así:

const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [shipping, setShipping] = useState(0);

// Función para agregar producto
const addItem = (product) => {
  const newItems = [...items, product];
  setItems(newItems);
  setTotal(calculateTotal(newItems));
  setShipping(calculateShipping(newItems));
  setDiscount(calculateDiscount(newItems));
};

Este código tiene varios problemas:

  • Múltiples estados que dependen entre sí
  • Lógica dispersa en diferentes funciones
  • Difícil de mantener y predecir
  • Posibles inconsistencias si un setState falla

Con useReducer, centralizamos toda esta lógica:

const [state, dispatch] = useReducer(cartReducer, initialState);

const addItem = (product) => {
  dispatch({ type: 'ADD_ITEM', payload: product });
};
💡 Regla mnemotécnica: Piensa en useReducer como una máquina expendedora. Le envías una acción (moneda + selección) y el reducer (máquina) decide qué hacer con ella y te devuelve un nuevo estado (el producto).

Sintaxis de useReducer

La sintaxis básica es:

const [state, dispatch] = useReducer(reducer, initialState, init?);
ParámetroDescripción
reducerFunción que recibe el estado actual y una acción, y devuelve el nuevo estado
initialStateEl estado inicial de tu componente
init (opcional)Función para calcular el estado inicial lazymente

Estructura del Reducer

El reducer es una función pura con esta estructura:

function reducer(state, action) {
  switch (action.type) {
    case 'ACCION_1':
      return { ...state, /* cambios */ };
    case 'ACCION_2':
      return { ...state, /* cambios */ };
    default:
      return state;
  }
}
📌 Recuerda: El reducer SIEMPRE debe ser una función pura. Esto significa que dados los mismos argumentos, siempre debe devolver el mismo resultado, sin efectos secundarios ni mutaciones del estado anterior.

Ejemplo Práctico: Formulario de Tareas

Vamos a construir un gestor de tareas completo con useReducer:

import { useReducer } from 'react';

// Estado inicial
const initialState = {
  tasks: [],
  filter: 'all', // 'all', 'active', 'completed'
  editingId: null,
};

// El reducer
function taskReducer(state, action) {
  switch (action.type) {
    case 'ADD_TASK':
      return {
        ...state,
        tasks: [
          ...state.tasks,
          {
            id: Date.now(),
            text: action.payload,
            completed: false,
            createdAt: new Date().toISOString(),
          },
        ],
      };

    case 'TOGGLE_TASK':
      return {
        ...state,
        tasks: state.tasks.map((task) =>
          task.id === action.payload
            ? { ...task, completed: !task.completed }
            : task
        ),
      };

    case 'DELETE_TASK':
      return {
        ...state,
        tasks: state.tasks.filter((task) => task.id !== action.payload),
      };

    case 'EDIT_TASK':
      return {
        ...state,
        tasks: state.tasks.map((task) =>
          task.id === action.payload.id
            ? { ...task, text: action.payload.text }
            : task
        ),
        editingId: null,
      };

    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload,
      };

    case 'START_EDITING':
      return {
        ...state,
        editingId: action.payload,
      };

    case 'CLEAR_COMPLETED':
      return {
        ...state,
        tasks: state.tasks.filter((task) => !task.completed),
      };

    default:
      return state;
  }
}

// Componente
function TaskManager() {
  const [state, dispatch] = useReducer(taskReducer, initialState);
  const [newTaskText, setNewTaskText] = useState('');

  // Derivar datos filtrados del estado
  const filteredTasks = state.tasks.filter((task) => {
    if (state.filter === 'active') return !task.completed;
    if (state.filter === 'completed') return task.completed;
    return true;
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    if (newTaskText.trim()) {
      dispatch({ type: 'ADD_TASK', payload: newTaskText.trim() });
      setNewTaskText('');
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={newTaskText}
          onChange={(e) => setNewTaskText(e.target.value)}
          placeholder="Nueva tarea..."
        />
        <button type="submit">Agregar</button>
      </form>

      <div className="filters">
        {['all', 'active', 'completed'].map((filter) => (
          <button
            key={filter}
            onClick={() => dispatch({ type: 'SET_FILTER', payload: filter })}
            className={state.filter === filter ? 'active' : ''}
          >
            {filter}
          </button>
        ))}
      </div>

      <ul>
        {filteredTasks.map((task) => (
          <li key={task.id} className={task.completed ? 'completed' : ''}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
            />
            {task.text}
            <button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })}>
              Eliminar
            </button>
          </li>
        ))}
      </ul>

      <button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
        Limpiar completadas
      </button>
    </div>
  );
}
⚠️ Importante: Nunca mutes el estado directamente. Siempre usa el spread operator o métodos que creen nuevos arrays/objetos. En el ejemplo, state.tasks.map() y state.tasks.filter() crean nuevos arrays.

Comparación: useState vs useReducer

CriteriouseStateuseReducer
Complexidad del estadoIdeal para estados simples (1-2 valores)Ideal para estados complejos con múltiples sub-valores
AccionesMúltiples setters dispersosAcciones centralizadas y predecibles
DebuggingDifícil rastrear cambiosFácil con Redux DevTools
RendimientoActualizaciones individualesOptimizado para actualizaciones batch
TestabilidadRequiere renderizar componenteReducer testeable aisladamente
📌 Regla práctica: Usa useState cuando tengas un valor booleano simple o un número. Usa useReducer cuando tengas 3 o más valores relacionados, o cuando la lógica de actualización sea compleja.

Patrones Avanzados con useReducer

1. Uso de Action Creators

Para reutilizar lógica de dispatch y evitar strings mágicos:

// Action creators
const actions = {
  addTask: (text) => ({ type: 'ADD_TASK', payload: text }),
  toggleTask: (id) => ({ type: 'TOGGLE_TASK', payload: id }),
  deleteTask: (id) => ({ type: 'DELETE_TASK', payload: id }),
  setFilter: (filter) => ({ type: 'SET_FILTER', payload: filter }),
};

// Uso
dispatch(actions.addTask('Nueva tarea'));
dispatch(actions.toggleTask(taskId));

2. Inicialización Lazy

Calcula el estado inicial de forma diferida:

function init(savedState) {
  return {
    tasks: savedState?.tasks || [],
    filter: savedState?.filter || 'all',
    lastUpdated: new Date().toISOString(),
  };
}

const [state, dispatch] = useReducer(reducer, savedState, init);

// Esto es equivalente a:
// useReducer(reducer, init(savedState))
💡 Beneficio: La inicialización lazy es útil cuando el cálculo inicial es costoso o cuando necesitas procesar datos antes de establecer el estado (como leer del localStorage).

3. Reducers con Thunks

Para acciones asíncronas, puedes combinar useReducer con useEffect:

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function DataComponent() {
  const [state, dispatch] = useReducer(reducer, {
    data: null,
    loading: false,
    error: null,
  });

  const fetchData = async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const data = await api.getData();
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  };

  return (
    <div>
      {state.loading && <p>Cargando...</p>}
      {state.error && <p>Error: {state.error}</p>}
      {state.data && <p>Datos: {JSON.stringify(state.data)}</p>}
      <button onClick={fetchData}>Obtener datos</button>
    </div>
  );
}

Combinando useReducer con Context

Para estado global compartido:

const TaskContext = createContext();

function TaskProvider({ children }) {
  const [state, dispatch] = useReducer(taskReducer, initialState);

  return (
    <TaskContext.Provider value={{ state, dispatch }}>
      {children}
    </TaskContext.Provider>
  );
}

// En cualquier componente hijo
function TaskList() {
  const { state, dispatch } = useContext(TaskContext);
  // ... usa state y dispatch
}
📌 Patrón recomendado: Esta combinación es similar a Redux pero más simple y sin dependencias externas. Perfecto para aplicaciones de tamaño mediano.
Ver más: Ejemplo completo con Context y localStorage
import { createContext, useContext, useReducer, useEffect } from 'react';

const TaskContext = createContext();

const STORAGE_KEY = 'tasks_state';

function loadInitialState() {
  try {
    const saved = localStorage.getItem(STORAGE_KEY);
    if (saved) {
      const parsed = JSON.parse(saved);
      return { ...initialState, ...parsed };
    }
  } catch (e) {
    console.error('Error loading from localStorage:', e);
  }
  return initialState;
}

function taskReducer(state, action) {
  // ... reducer logic
}

export function TaskProvider({ children }) {
  const [state, dispatch] = useReducer(
    taskReducer,
    undefined,
    () => loadInitialState()
  );

  // Persistir en localStorage
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state]);

  return (
    <TaskContext.Provider value={{ state, dispatch }}>
      {children}
    </TaskContext.Provider>
  );
}

export function useTasks() {
  const context = useContext(TaskContext);
  if (!context) {
    throw new Error('useTasks debe usarse dentro de TaskProvider');
  }
  return context;
}

Errores Comunes y Cómo Evitarlos

  1. Mutación directa del estado: Usa siempre spread operator o métodos inmutables. state.items.push() romperá React.
  2. Olvidar el default en el switch: Siempre incluye default: return state; para acciones desconocidas.
  3. Referencias circulares en initialState: Evita que el estado inicial se referencie a sí mismo.
  4. No separar el reducer: Mantén el reducer como función separada para mejor testabilidad.
⚠️ Debugging tip: Usa console.log o Redux DevTools para rastrear las acciones. En desarrollo, puedes crear un wrapper de dispatch que registre todas las acciones:
function useReducerWithLogger(reducer, initialState) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const dispatchWithLogger = (action) => {
    console.log('Estado actual:', state);
    console.log('Acción:', action);
    dispatch(action);
  };

  return [state, dispatchWithLogger];
}
El reducer bien diseñado es como código bien escrito: expresivo, predecible y fácil de modificar. Invierte tiempo en diseñarlo y te ahorrará mucho más tiempo después.

Cuándo NO usar useReducer

Aunque useReducer es poderoso, no siempre es la mejor opción. Evita usarlo cuando:

  • Tu estado es simple (un boolean, un string, un número)
  • Las actualizaciones son independientes entre sí
  • El componente es pequeño y no tiene lógica compleja
  • Estás prototipando rápidamente
💡 Conseil: Si sientes que estás escribiendo mucho código para una tarea simple, probablemente useState es mejor opción. Usa la herramienta correcta para cada trabajo.

Resumen de la Lección

En esta lección hemos aprendido:

  • Qué es useReducer y cómo difiere de useState para manejo de estado complejo
  • La estructura del reducer: función pura que recibe estado y acción, devuelve nuevo estado
  • Actions con type y payload: convención estándar para describir qué cambió y con qué datos
  • Patrones avanzados: action creators, inicialización lazy, combinación con Context
  • Cuándo usar useReducer: estado complejo, múltiples sub-estados relacionados, lógica predecible
🧠 Quiz

¿Cuál es la principal ventaja de usar useReducer sobre múltiples llamadas a useState?

  • A) Es más rápido en ejecución
  • B) Centraliza la lógica de actualización y hace el código más predecible
  • C) Usa menos memoria
  • D) Funciona sin React
✅ Respuesta correcta: B. useReducer centraliza toda la lógica de actualización en un solo lugar (el reducer), haciendo el código más mantenible, testeable y predecible. Aunque useState puede ser ligeramente más rápido en casos simples, la ventaja principal de useReducer es la organización y previsibilidad del código.
🧠 Quiz

¿Cuál de las siguientes afirmaciones sobre el reducer es CORRECTA?

  • A) Puede modificar el estado directamente usando state.nuevaPropiedad = valor
  • B) Debe ser una función pura que no mute el estado original
  • C) Solo puede manejar un tipo de acción
  • D) Debe usar async/await para operaciones asíncronas
✅ Respuesta correcta: B. El reducer SIEMPRE debe ser una función pura que no muta el estado. Debe devolver un nuevo objeto/array de estado, nunca modificar el anterior. Esto es fundamental para que React pueda detectar cambios y re-renderizar correctamente.

Próximos Pasos

Ahora que dominas useReducer, estás listo para:

  • Explorar useContext + useReducer para estado global sin Redux
  • Aprender Custom Hooks que abstraen lógica con useReducer
  • Profundizar en patrones de estado como reducer composition
📌 Preparación: En la próxima lección exploraremos cómo crear Custom Hooks que encapsulen lógica con useReducer, permitiéndote reutilizar patrones de estado complejos en toda tu aplicación.