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:

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.