Introducción
En proyectos de Frontend, es común asociar el uso de Context únicamente con evitar el "Prop Drilling" (pasar props a través de múltiples niveles de componentes). Sin embargo, este es solo el síntoma superficial de un problema mayor: La fragmentación de la Lógica de Negocio.
Cuando gestionamos el estado de una entidad compleja (como un Carrito de Compra) disperso en múltiples useState a través de componentes hermanos, nos enfrentamos a problemas más graves:
- Inconsistencia de Datos: Si dos componentes intentan actualizar la misma lista de formas distintas, es fácil perder la sincronización (ej: actualizar un contador en un lugar pero olvidar hacerlo en otro).
-
Acoplamiento de UI y Lógica: Los componentes de presentación terminan llenos de funciones de manipulación de datos (
handleAdd,handleDelete), haciéndolos difíciles de testear y reutilizar. - Dificultad de Refactorización: Cambiar la estructura de un dato implica buscar y refactorizar en 10 archivos diferentes.
¿Por qué combinar Context + Reducer?
La combinación de Context + Reducer no solo resuelve el transporte de datos (Context), sino que impone una arquitectura unidireccional para las modificaciones (Reducer).
Al combinar ambos patrones obtenemos:
- Fuente de Verdad Única: El estado no solo "vive" en un lugar, sino que solo puede cambiar de formas predefinidas.
- Lógica Centralizada: Las reglas de negocio viven en el reducer, no en los componentes de UI.
-
Desacoplamiento Real: Tus componentes solo dicen "qué pasó" (
dispatch({ type: 'DELETE_LEAD' })), sin preocuparse de "cómo" se actualiza el estado.
Lo aprendido
Si bien, son dos patrones que se suelen aprender por separado, combinandolos pueden tener un uso robusto para aplicaciones que van escalando con el paso del tiempo.
Estas son soluciones nativas de hooks de React, pero no está de mas darle un vistazo a paquetes de terceros que pueden ayudarnos a manejar estos casos, como lo es Zustand o Redux.
Pero vamos a centrarnos en una solución con hooks nativos de React
Ejemplo
El caso es el siguiente: tengo un listado de Leads que son creados desde el Frontend con persistencia en LocalStorage (por motivos de prueba) y este sistema permitirá llevar el control de Nuevos Leads con la finalidad de cerrarlos como clientes.
Tengo una pantalla principal de Leads para mostrar todos los que han sido creados, pero tengo otra pantalla tipo Kanban que permite mostrar los Leads creados y actualizarles el estado.
Es necesario un punto central para obtener los leads, actualizarlos y/o eliminarlos, ademas de algunos otros procedimientos consecuente de las acciones
Lo primero que vemos es que ambos componentes (pages) requieren de comunicarse y obtener los mismos leads. Entra entonces en escena el Context el cual nos permitira manejar un contexto centralizado entre las paginas que necesitamos.
Para este caso, crearemos dos contextos, en uno de los contextos estaremos manejando el estado junto con el estado del reducer y el segundo contexto nos permitirá acceder a los métodos que modificarán el estado. ¿Por que se hace de esta manera? porque si un componente solo necesita llamar a alguno de los metodos addLead updateLead etc y tenemos tanto el estado como las acciones en el mismo contexto, el componente que solo necesita de los metodos sufrira de un renderizado si el listado de Leads cambia, por lo cual no es un efecto que esperamos
import {
createContext,
useContext,
useEffect,
useMemo,
useReducer,
type PropsWithChildren,
} from "react";
// 1. Definimos las acciones disponibles
// Este objeto actuará como nuestra API pública para modificar el estado.
// Desacopla la implementación (reducer) de UI.
type LeadsDispatch = {
addLead: (lead: Omit<Lead, "id" | "dateAdded" | "column">) => void;
updateLead: (leadId: string, leadData: Partial<Lead>) => void;
setLeads: (leads: Lead[]) => void;
deleteLead: (leadId: string) => void;
};
// 2. Creamos dos contextos separados (Estado y Dispatch)
// Separamos la lectura (State) de la escritura (Dispatch) para evitar re-renders innecesarios.
// Si un componente solo necesita 'addLead', no debería renderizarse cuando cambia la lista de 'leads'.
const LeadsStateContext = createContext<LeadsState | undefined>(undefined);
const LeadsDispatchContext = createContext<LeadsDispatch | undefined>(
undefined,
);
// 3. Inicialización Lazy (para leer localStorage solo una vez)
// Optimizamos el inicio de la app leyendo del almacenamiento local solo en el montaje inicial.
const getInitialState = (): LeadsState => {
if (typeof window === "undefined") return { leads: [], leadsCount: 0, newLeadsCount: 0, qualifiedLeadsCount: 0 };
const leadsRaw = localStorage.getItem("leads");
const leads: Lead[] = leadsRaw ? JSON.parse(leadsRaw) : [];
// Calculamos estado derivado inicial (contadores)
return {
leads,
leadsCount: leads.length,
newLeadsCount: leads.filter((l) => l.status === "NEW").length,
qualifiedLeadsCount: leads.filter((l) => l.status === "QUALIFIED")
.length,
};
};
export const LeadsProvider = ({ children }: PropsWithChildren) => {
// Pasamos la función inicializadora como 3er argumento a useReducer
// Esto evita ejecutar getInitialState en cada render del Provider.
const [state, dispatch] = useReducer(
leadsReducer,
undefined,
getInitialState,
);
// Memoizamos las acciones para mantener la referencia estable entre renders.
// Esto es crucial para que los consumidores de Dispatch no se re-rendericen.
const actions = useMemo<LeadsDispatch>(
() => ({
addLead: (lead) => dispatch({ type: "ADD_LEAD", payload: lead }),
deleteLead: (id) => dispatch({ type: "DELETE_LEAD", payload: id }),
updateLead: (id, leadData) =>
dispatch({ type: "UPDATE_LEAD", payload: { id, leadData } }),
setLeads: (leads) => dispatch({ type: "SET_LEADS", payload: leads }),
}),
[],
);
// Efecto secundario: Persistencia
// Cada vez que el estado cambia, sincronizamos con localStorage.
useEffect(() => {
localStorage.setItem("leads", JSON.stringify(state.leads));
}, [state.leads]);
return (
<LeadsStateContext value={state}>
<LeadsDispatchContext value={actions}>{children}</LeadsDispatchContext>
</LeadsStateContext>
);
};
// 4. Custom Hooks para consumir el contexto de forma segura
// Abstraemos el useContext y validamos que se use dentro del Provider.
export const useLeadsState = () => {
const context = useContext(LeadsStateContext);
if (context === undefined)
throw new Error("useLeadsState debe usarse dentro de LeadsProvider");
return context;
};
export const useLeadsDispatch = () => {
const context = useContext(LeadsDispatchContext);
if (context === undefined)
throw new Error("useLeadsDispatch debe usarse dentro de LeadsProvider");
return context;
};
// Hook helper para seleccionar un lead específico (Selector Pattern)
// Evita lógica repetitiva de búsqueda en los componentes.
export const useLeadById = (id: string) => {
const { leads } = useLeadsState();
return leads.find((l) => l.id === id);
};
El Reducer: Donde vive la Lógica de Negocio
El reducer no solo actualiza el dato, sino que mantiene la consistencia de todo el estado. Observa cómo al añadir un lead, también recalculamos los contadores automáticamente.
export const leadsReducer = (state: LeadsState, action: LeadsAction): LeadsState => {
switch (action.type) {
case "ADD_LEAD":
const leadData = action.payload;
const newLead = {
...leadData,
id: crypto.randomUUID(),
dateAdded: new Date().toISOString(),
column: leadData.status // Sincroniza columna con estado
};
const newLeads = [...state.leads, newLead];
// Lógica atómica: Actualizamos lista y contadores en una sola pasada
return {
...state,
leads: newLeads,
leadsCount: newLeads.length,
newLeadsCount: leadData.status === "NEW" ? state.newLeadsCount + 1 : state.newLeadsCount,
};
// ... otros casos (UPDATE, DELETE)
default:
return state;
}
};
Consumo en Componentes: Limpio y Desacoplado
Gracias a nuestros custom hooks, los componentes quedan extremadamente limpios y desacoplados.
Ejemplo 1: Componente de solo lectura (Stats)
Este componente no se re-renderizará si alguien solo dispara una acción sin cambiar el estado.
const LeadsStats = () => {
// Solo obtenemos lo que necesitamos
const { leadsCount, newLeadsCount } = useLeadsState();
return (
<div className="stats-grid">
<StatCard label="Total Leads" value={leadsCount} />
<StatCard label="New Leads" value={newLeadsCount} />
</div>
);
};
Ejemplo 2: Componente de solo escritura (Botón Agregar)
Este componente no se re-renderizará aunque cambie la lista de leads, porque useLeadsDispatch es estable.
const AddLeadButton = () => {
// Solo importamos la acción, sin "escuchar" cambios de estado
const { addLead } = useLeadsDispatch();
const handleCreate = () => {
// podemos obtener la info desde un formulario
addLead({
name: "John Doe",
email: "john@example.com",
status: "NEW",
company: "Acme Inc"
});
};
return <button onClick={handleCreate}>Add New Lead</button>;
};
Conculsiónes
- Esta es una solución que te permitirá aprovechar todas las herramientas de React, si debes priorizar el bundle final de tu proyecto, ya que no necesitaras utilizar librerias de terceros
- Al utilizar los reducers, tendrás una fuente de verdad para las modificaciónes del estado.
- Si bien es una solución que te permite seguir un orden, también puede hacer escribir más codigo de lo esperado cuando quieres agregar algo simple
Top comments (0)