Esquivando re-renders en React: de useEffect a los signals.
Hace unas semanas, al revisar la newsletter de TLDR Dev, me encontré con el artículo de Alvin Sng en Twitter, que habla de cómo en Factory han prohibido el uso de useEffect(). Al principio lo empecé a leer con escepticismo, pero profundizando vi que hasta la propia documentación de React tiene una guía al respecto.
Según la documentación oficial, deberíamos usar useEffect solo para cosas muy específicas:
- Sincronizar con sistemas externos: Para conectar nuestros componentes con elementos que React no controla.
- Ejemplos: Manipular el DOM directamente, integrar bibliotecas de terceros (como un mapa), o establecer suscripciones a redes (como un WebSocket).
- Ejecutar lógica “detonada” por la aparición del componente: Para código que debe ejecutarse exclusivamente porque el componente se mostró en la pantalla.
- Ejemplo: Enviar un evento de analíticas (como registrar una visita o acción).
- Obtener datos: Para pedir datos a un servidor.
- Ejemplo: Hacer una petición a una API cada vez que cambian los parámetros de búsqueda. (Nota: React aclara que esto es válido, pero hoy en día recomiendan usar herramientas especializadas para el fetching de datos).
Y la verdad es que solemos (me incluyo el primero) usar el useEffect() por encima de nuestras posibilidades, más allá de para lo que fue pensado. Es una herramienta súper útil, pero al usarla para todo provocamos una serie de errores que van desde loops infinitos hasta re-renderizados que no son necesarios y me propuse mejorar un pequeño proyecto que tengo.
Así que me puse a ello, dejo ejemplo vibecodeado de ejemplo:
// COMO NO se tiene que hacer
import { createContext, useContext, useState, useEffect } from 'react';
const CartContext = createContext();
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// Efecto Cascada (Doble render)
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
return (
<CartContext.Provider value={{ items, setItems, total }}>
{children}
</CartContext.Provider>
);
}
// COMPONENTE 1: La barra de navegación superior
function Navbar() {
const { total } = useContext(CartContext);
console.log("Navbar renderizado");
return <nav>Total a pagar: {total}€</nav>;
}
// COMPONENTE 2: El botón de compra
function BuyButton() {
const { items, setItems } = useContext(CartContext);
console.log("Botón renderizado");
return (
<button onClick={() => setItems([...items, { price: 10 }])}>
Añadir Producto
</button>
);
}
// Quitando useEffect innecesarios
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<CartContext.Provider value={{ items, setItems, total }}>
{children}
</CartContext.Provider>
);
}
function Navbar() {
const { total } = useContext(CartContext);
return <nav>Total a pagar: {total}€</nav>;
}
function BuyButton() {
const { items, setItems } = useContext(CartContext);
return (
<button onClick={() => setItems([...items, { price: 10 }])}>
Añadir Producto
</button>
);
}
Con esto nos quitaríamos el problema del useEffect y su doble render. Pero aquí viene el otro problema de React: al usar un Context, hacemos click en el botón, y tanto el NavBar como el botón se volverán a renderizar porque ambos consumen ese contexto.
Aquí da igual, es un ejemplo pequeño y no pasa nada, pero multipliquemos el número de componentes por unos cuantos más. Puede llegar el caso de que hagamos click en un botón, y de golpe se tenga que renderizar media web.
Así que quise optimizar esto y empecé a meterme en el mundo de los Signals. Una librería muy conocida es Preact Signals, pero como estaba aburrido en casa, quise hacer mi propia implementación del sistema para entender cómo iba todo este tema por debajo. Así nació Minisignals.
Sigamos con el código usando signals:
Creamos nuestro store fuera de React:
// store.ts (Totalmente agnóstico de React)
import { signal, computed } from '@hoosk/minisignals';
export const items = signal([]);
// El total se calcula de forma "lazy". Sin efectos secundarios.
export const total = computed(() => {
return items.value.reduce((sum, item) => sum + item.price, 0);
});
Y después lo implementamos en nuestros componentes:
// app.tsx (Nuestros componentes)
import { useSignalValue } from '@hoosk/minisignals-react';
import { items, total } from './store';
// COMPONENTE 1
function Navbar() {
// Solo escucha al signal 'total'
const currentTotal = useSignalValue(total);
console.log("Navbar renderizado");
return <nav>Total a pagar: {currentTotal}€</nav>;
}
// COMPONENTE 2
function BuyButton() {
console.log("Botón renderizado");
return (
// Mutación, como si fuera JS normal.
<button onClick={() => items.value = [...items.value, { price: 10 }]}>
Añadir Producto
</button>
);
}
Ahora, al hacer click en el BuyButton, mutamos la memoria de JS (items.value). El signal avisa al computed local, y el hook useSignalValue le dice a React que solo renderice el Navbar. El botón ya no se re-renderiza porque no está escuchando ningún evento.
Este ejemplo es sencillo, pero la verdad es que hacer esta librería tan chiquita ha sido genial, he aprendido muchísimo. Me he centrado en que funcione bien y sea muy ligera. Preact Signals es MUCHÍSIMO más potente y está mejor hecha, pero hay veces que no necesitamos un camión, sino una moto. Y creo que este enfoque simplifica mucho el trabajo, sobre todo para proyectos más pequeños o medianos. Mi idea es que se pueda acabar usando en JS vanilla, React, Angular y Vue, aunque en estas dos últimas quizás no sea necesario por como funcionan.
Si tenéis tiempo y queréis hecharle un ojo o dar feedback sobre Minisignals la verdad es que estaría muy agradecido.