React es una de las librerías de código abierto más conocidas para la construcción de UI y está basada en principios de programación funcional y reactiva:
- Los componentes están declarados como funciones puras que reciben propiedades como argumento y devuelven elementos de React (que pueden ser elementos del DOM,
string
,number
,boolean
e inclusonull
oundefined
si no queremos “pintar” nada). - Como su propio nombre indica, una de las ventajas de los componentes es que se pueden componer, construyendo componentes más grandes a partir de otros más pequeños.
- Se promueve el uso de estructuras de datos inmutables. No se modifica directamente el estado, si no que se crea una nueva versión del estado que incluye los cambios necesarios.
- Los componentes reaccionan a los cambios de estado, volviéndose a renderizar de forma eficiente para aplicar estos cambios.
- La ejecución de efectos colaterales se produce dentro del hook
useEffect
, lo que asegura que estos también responderán a los cambios de estado.
¿Qué significa que un componente se renderiza?
React funciona con el llamado Virtual DOM, que no es más que una representación del DOM en memoria.
Cuando detecta un cambio, construye el nuevo Virtual DOM y lo compara con el anterior en el proceso llamado diferenciación (o diffing).
Una vez que React ha determinado qué partes del Virtual DOM real han cambiado, si es necesario, aplica las actualizaciones necesarias sobre el DOM real, pudiendo agrupar múltiples actualizaciones en la misma operación (batching).
Hay que remarcar que incluso cuando el DOM real no cambia, esta comparación entre estados del Virtual DOM consumirá recursos.
¿Cuando se renderiza un componente en React?
Por defecto, un componente se renderiza:
Cuando la aplicación se inicia
Cuando llamamos a createRoot
y luego al método render
en el objeto devuelto o bien cuando usamos un framework como Next que se encarga de construir la estructura inicial de la página y convertirla a código HTML que posteriormente se devuelve como respuesta.
Cuando se produce un cambio en el estado
En React el estado se gestiona mediante el uso de los hooks useState
, useReducer
o useSyncWithExternalStore
.
En el caso más sencillo (useState
) el hook devuelve una tupla donde la primera posición es el valor actual de ese estado y la segunda posición es un dispatcher (updater) que nos permite actualizar el valor de ese estado de tal forma que el árbol de componentes transforme ese cambio de estado en un cambio en la UI.
Un cambio de estado en el padre siempre se propaga a los hijos
Un componente siempre se re-renderizará cuando se modifique su estado interno.
En el caso de un componente compuesto, que se encargue de invocar a otros subcomponentes, es importante reseñar que, por defecto, estos subcomponentes siempre se re-renderizarán cuando el estado del componente padre cambie, aun cuando las props recibidas no cambien o cuando no dependan de ninguna prop.
/**
* En este ejemplo, Intro, Wargning y Label se re-renderizarían
* cuando cambie "counter"
*/
const MyComponent = () => {
const [counter, setCounter] = useState(0);
return (
<div>
<Intro />
<Warning isGt10={counter > 10} />
<button type="button" onClick={() => setCounter(prev => prev + 1)}>
Increment
</button>
<Label counter={counter} />
<button type="button" onClick={() => setCounter(prev => prev - 1)}>
Decrement
</button>
</div>
)
}
Lenguaje del código: JavaScript (javascript)
Strict mode
Hay que mencionar que cuando trabajamos con el modo estricto activo en React nuestros componentes y efectos se ejecutan por partida doble. Habrá que tener esto en cuenta a la hora de hacer nuestras pruebas y comparar resultados, bien desactivándolo o bien contando con esta ejecución doble.
memo
Mediante el uso de la utilidad memo podemos memoizar un componente, de tal forma que solo reaccione a cambios en las props que recibe, aun cuando el estado interno de su padre cambie.
import { memo } from 'react';
interface Props {
isGt10: boolean;
}
const Warning = ({ isGt10 }: Props) => isGt10 && (
<div className="warning">Es mayor que 10</div>
);
const MemoizedWarning = memo(Warning);
Lenguaje del código: JavaScript (javascript)
Ahora, MemoizedWarning
solo se re-renderizaría si cambia isGt10
(siempre que no se le pasen otras props no declaradas).
La utilidad memo permite un segundo parámetro opcional, que permite definir de qué forma se compararán las props anteriores con las nuevas a la hora de evaluar si el componente debe re-renderizarse. Sin embargo, siempre deberíamos primar el enviar solo la información imprescindible a nuestro componente.
import { memo } from 'react';
interface Props {
counter: number;
}
const Warning = ({ counter }: Props) => counter > 10 && (
<div className="warning">Es mayor que 10</div>
);
const MemoizedWarning = memo(Warning, (oldProps, newProps) => {
const oldCounterIsGt10 = oldProps.counter > 10;
const newCounterIsGt10 = newProps.counter > 10;
return oldCounterIsGt10 !== newCounterIsGt10;
});
Lenguaje del código: JavaScript (javascript)
Aunque este código sería perfectamente funcional, por un lado estaría otorgando la responsabilidad de definir si el warning se debe mostrar o no al propio componente, dificultando su posterior reutilización, y además la evaluación siempre será más lenta que comparar simplemente con el operador de igualdad, que es la comparación por defecto.
Sin embargo, en algunas ocasiones nos podemos encontrar componentes complejos con problemas de performance donde la introducción de esta función de comparación personalizada nos puede ayudar a corregirlos. En cualquier caso, debemos ser cuidadosos, ya que estamos modificando el comportamiento normal del componente y esto nos puede introducir problemas en forma de componentes desactualizados.
¿A qué llamamos “componente hijo”?
Hemos hecho mención a los componentes hijo en diferentes ocasiones, y esto puede causar confusión con la prop children
, que es la convención que tiene React para pasar un componente anidado.
Por simplicidad, cuando hagamos mención a “Componentes padre” nos referiremos a aquellos responsables de renderizar a los “Componentes hijo”. Veamos el siguiente caso:
interface Level2AProps {
children: ReactNode;
}
const Level2A = ({ children }: Level2AProps) => {
const [counter, setCounter] = useState(0);
return (
<div>
{children}
<Warning isEnabled={counter >= 10}>Has llegado al límite</Warning>
<button type="button" onClick={() => setCounter(prev => prev + 1)}>
Increment
</button>
<Label counter={counter} />
<button type="button" onClick={() => setCounter(prev => prev - 1)}>
Decrement
</button>
</div>
)
}
const Level2B = () => (
<p>Este componente no se re-renderizará</p>
)
const Level1 = () => (
<Level2A>
<Level2B />
</Level2A>
);
Lenguaje del código: JavaScript (javascript)
Level1
es el responsable de renderizarLevel2A
yLevel2B
(usando el patrón llamado “composición de contenido” o “content composition pattern”).Level1
no tiene estado interno, por lo que, a menos que se vea afectado por el responsable de renderizarlo, no va a cambiar.- Al no cambiar
Level1
, no propagará ese cambio aLevel2A
yLevel2B
. Level2A
sí que tiene un estado interno. Si este estado cambia, todos los componentes de cuya renderización es responsable se actualizarán.Level2A
no es el responsable de renderizarLevel2B
, sino que recibe un valor a través de la propchildren
.- Con todo lo anteriormente descrito podemos deducir que ni
Level1
niLevel2B
se re-renderizarían cuando se modificara el estado decounter
en el componenteLevel2A
¿Cuándo y por qué evitar re-renderizaciones innecesarias?
Comencemos por lo más simple: cuándo no merece la pena preocuparse por las re-renderizaciones.
/**
* En casos como este, aplicar un memo no merece la pena
*/
interface Props {
headerContent: ReactNode;
mainContent: ReactNode;
}
const MyLayout = ({ headerContent, mainContent }) => (
<div>
<header>{headerContent}</header>
<main>{mainContent}</main>
</div>
);
Lenguaje del código: JavaScript (javascript)
El componente anterior es muy simple. Por lo tanto, es más costoso evitar este re-render que dejar que simplemente suceda.
Sin embargo no todos los componentes son igual de livianos. Podemos encontrar:
- Listados enormes, costosos de re-renderizar.
- Componentes que hacen uso de
useEffect
para comunicarse con librerías de terceros imperativamente, donde necesitamos asegurarnos que el componente no se actualiza de forma innecesaria. - Componentes que contienen transiciones visuales que no queremos que se interrumpan.
En cualquiera de estos casos, el conocimiento de las diferentes herramientas que React nos proporciona nos dotará de una mayor versatilidad a la hora de asegurarnos que nuestras aplicaciones son eficientes y resilientes.
useMemo
El hook useMemo
sirve mayoritariamente para dos propósitos:
- Evitar que un procesamiento costoso se vuelva a ejecutar de forma innecesaria si no ha cambiado ninguno de los datos de los que depende.
- Evitar generar una nueva referencia a la hora de realizar transformaciones que devuelvan arrays u objetos si no han cambiado sus dependencias (esto es especialmente útil si estamos pasando esa prop derivada a un componente envuelto con
memo
o si estamos usando este dato derivado en unuseEffect
).
// "computeExpensiveOperation" solo se ejecuta si "lines" es diferente
// al renderizado anterior
const total = useMemo(() => computeExpensiveOperation(lines), [lines]);
// "parsedOptions" mantendría su referencia siempre que "options" no cambie
const parsedOptions = useMemo(() => options.map(({ label, value }) => ({
label,
value
})), [options]);
useEffect(() => {
trackAnalytics('options_loaded', parsedOptions);
}, [parsedOptions]);
Lenguaje del código: JavaScript (javascript)
Para evitar inconsistencias, es importante que declaremos correctamente todas las dependencias que use. La config oficial de React para eslint nos puede ser de gran ayuda al avisarnos si estamos incumpliendo esta norma.
useCallback
El hook useCallback
es azúcar sintáctico para mejorar la legibilidad a la hora de memoizar una función.
const handleClick = useCallback(
(value: string) => {
performSearch(value);
},
[performSearch],
);
// Con useMemo aunque posible y equivalente, es menos legible
const handleClick2 = useMemo(
() => (value: string) => {
performSearch(value);
},
[performSearch],
);
Lenguaje del código: JavaScript (javascript)
El objetivo de memoizar una función es evitar la creación de nuevas referencias entre renderizados del componente, lo cuál puede ser útil si estamos pasando esta función como prop a un componente hijo que está envuelto en memo
.
const [items, setItems] = useState(initialItems);
const completedItems = useMemo(
() => items.filter(({ isComplete }) => isComplete),
[items],
);
const handleConfirmCleaning = useCallback(
() => setItems(prevItems => prevItems.filter(({ isComplete }) => isComplete),
[],
);
const handleUnmarkItem = useCallback(
(id: number) => {
setItems(prevItems => prevItems.map((prevItem) => {
if (prevItem.id !== id) {
return prevItem;
}
return {
...prevItem,
isComplete: false,
};
}));
},
[],
);
return (
<MemoizedExpensiveLayout handleConfirmCleaning={handleConfirmCleaning}>
<MemoizedExpensiveComponent
completedItems={completedItems}
handleUnmarkItem={handleUnmarkItem}
/>
</Layout>
);
Lenguaje del código: JavaScript (javascript)
Context API
Como hemos visto anteriormente, siempre que un componente actualiza su estado interno, provoca un re-renderizado de todos los componentes hijo que no estén envueltos con memo
, sin importar si dependen de este estado o no.
Esto nos lleva a una de las máximas de React: “pon tu estado en el nivel más bajo que te sea posible”.
¿Y qué pasa cuando tenemos componentes que están en diferentes niveles de la jerarquía y que necesitan acceder a un estado compartido?
Por un lado nos vemos obligados a pasar la prop items
entre componentes intermedios (esto es, que no utilizan la prop) y por otro lado todos los componentes se van a re-renderizar cuando items
cambie (incluso si están envueltos con memo
, puesto que dependen de items
para pasarla al componente hijo).
Y justo para estos casos los contextos (Context API) en React nos vienen genial: Nos permiten propagar cualquier dato hacia abajo sin que los componentes intermedios se vean afectados por las actualizaciones, solo aquellos que estén explícitamente suscritos.
// Creamos el contexto "ItemContext", así como un "Provider" que lo propagará
// hacia abajo y un hook llamado "useItemsState" que nos permite obtenerlo
const ItemsContext = createContext<{
items: Item[];
addItem: (item: Item) => void;
resetItems: () => void;
} | undefined>(undefined);
const ItemsProvider = ({ children }: { children: ReactNode }) => {
const [items, setItems] = useState<Item[]>([]);
const value = useMemo(() => ({
items,
addItem: (item: Item) => {
setItems(prevItems => [...prevItems, item]),
},
resetItems: () => setItems([]),
}), [items]);
return <ItemsContext.Provider value={value}>{children}</ItemsContext.Provider>;
};
const useItemsState = () => {
const value = useContext(ItemsContext);
if (!value) {
throw new Error('useItemsContext must be used inside an ItemsProvider');
}
return value;
};
// Envolvemos todos los componentes que dependan de este contexto
const MyApp = () => (
<ItemsProvider>
<Layout topBar={<TopBar />} sideBar={<SideBar />}>
<ItemList />
</Layout>
</ItemsProvider>
);
Lenguaje del código: JavaScript (javascript)
Una vez creado el contexto y envueltos nuestros componentes con el Provider correspondiente, solo nos queda acceder a este contexto en los componentes que realmente lo necesiten:
// Este componente podría ser renderizado por "SideBar" por ejemplo:
const ResetButton = () => {
const { resetItems } = useItemsState();
return <button type="button" onClick={resetItems}>Reset Items</button>
};
const ItemList = () => {
const { items } = useItemsState();
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
};
Lenguaje del código: JavaScript (javascript)
En este caso ResetButton se re-renderizaría cada vez que items cambie, a pesar de no necesitar esa prop (incluso aunque envolviéramos el componente con memo
). Esto es así porque no podemos suscribirnos a partes de un contexto, es todo o nada.
En componentes simples esto no va a suponer ningún problema, pero en componentes complejos tendríamos diferentes alternativas:
- Crear contextos independientes para las funciones que actualizan el estado (que es lo que necesita
ResetButton
) y para el valor del estado (que es lo que necesitaItemList
). - Si estamos tratando con estados y/o componentes realmente complejos, podríamos hacer uso de una librería de gestión de estado (Jotai, Recoil, Zustand, Redux…). Estas librerías no solo nos permiten suscribirnos a las funciones de actualización de forma independiente, si no que nos permiten crear estados derivados: Imaginemos que tenemos un componente complejo que solo se debe mostrar si hay algún item. Podríamos crear un selector a partir del listado de items que devuelva un
boolean
, que es a lo que finalmente se suscribiría nuestro componente.
Conclusión
Conocer cómo funciona el ciclo de renderizado de React y las herramientas disponibles nos será útil para evitar renderizaciones innecesarias en componentes costosos, a evitar que nuestros efectos se ejecuten por duplicado innecesariamente y a discriminar cuándo no merece la pena aplicar una técnica de memoización.