Separar eventos de Efectos
Los controladores de eventos solo se vuelven a ejecutar cuando vuelves a realizar la misma interacción. A diferencia de los controladores de eventos, los Efectos se resincronizan si algún valor que leen, como una prop o una variable de estado, es diferente de lo que era durante la última renderización. A veces, también quieres una mezcla de ambos comportamientos: un Efecto que se vuleve a ejecutar en respuesta a algunos valores pero no a otros. Esta página te enseñará cómo hacerlo.
Aprenderás
- Cómo escoger entre un controlador de evento y un Efecto
- Por qué los Efectos son reactivos, y los controladores de eventos no lo son
- Qué hacer cuando quieres que una parte del código de tu Efecto no sea reactivo
- Qué son los eventos de Efecto y cómo extraerlos de tus Efectos
- Cómo leer las últimas props y estados de los Efectos usando Eventos de Efecto
Elegir entre controladores de eventos y Efectos
Primero, vamos a recapitular la diferencia entre controladores de eventos y Efectos.
Imagina que estas implementando un componente de sala de chat. Tus requerimientos se verán así:
- Tu componente debería conectarse de forma automática a la sala de chat seleccionada.
- Cuándo hagas click al botón «Enviar», debería enviar un mensaje al chat.
Digamos que ya tienes el código implementado para ello, pero no estas seguro de donde ponerlo. ¿Deberías de usar controladores de eventos o Efectos? Cada vez que necesites contestar este pregunta, considera por qué se necesita ejecutar el código.
Los controladores de eventos se ejecutan en respuesta a interacciones especificas
Desde la perspectiva del usuario, el envío de un mensaje debe producirse porque se hace clic en particular en el botón «Enviar». El usuario se enfadará bastante si envías su mensaje en cualquier otro momento o por cualquier otro motivo. Esta es la razón por la que enviar un mensaje debería ser un controlador de evento. Los controladores de eventos te permiten controlar interacciones específicas:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Enviar</button>;
</>
);
}
Con un controlador de Evento, puedes estar seguro que sendMessage(message)
únicamente se activará si el usuario presiona el botón.
Los Efectos se ejecutan siempre que es necesaria la sincronización
Recuerda que también necesitas mantener el componente conectado a la sala de chat. ¿Dónde va ese código?
La razón para ejecutar este código no es ninguna interacción en particular. No es importante, el cómo o de qué forma el usuario navegó hasta la sala de chat. Ahora que ellos están viéndola y pueden interactuar con ella, el componente necesita mantenerse conectado al servidor de chat seleccionado. Incluso si el componente de la sala de chat fuera la pantalla inicial de tu aplicación y el usuario no ha realizado ningún tipo de interacción, todavía necesitarías conectarte. Es por eso que es un Efecto:
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
Con este código, puedes estar seguro que siempre hay una conexión activa al servidor de chat seleccionado actualmente, independientemente de las interacciones específicas realizadas por el usuario. Si el usuario solo ha abierto tu aplicación, seleccionado una sala diferente o navegado a otra pantalla y volvió, tu Efecto garantiza que el componente permanecerá sincronizado con la sala seleccionada a ctualmente, y volverá a conectarse cuando sea necesario.
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSendClick() { sendMessage(message); } return ( <> <h1>¡Bienvenido a la sala {roomId}!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> <button onClick={handleSendClick}>Enviar</button> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); const [show, setShow] = useState(false); return ( <> <label> Selecciona la sala de chat:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="viaje">viaje</option> <option value="música">música</option> </select> </label> <button onClick={() => setShow(!show)}> {show ? 'Cerrar chat' : 'Abrir chat'} </button> {show && <hr />} {show && <ChatRoom roomId={roomId} />} </> ); }
Valores reactivos y lógica reactiva
Intuitivamente, podría decirse que los controladores de eventos siempre se activan «manualmente», por ejemplo, al pulsar un botón. Los Efectos, en cambio, son «automáticos»: se ejecutan y se vuelven a ejecutar tantas veces como sea necesario para mantenerse sincronizados.
Hay una forma más precisa de pensar en esto.
Las propiedades, estados, y variables declarados dentro del cuerpo de tu componente son llamados valores reactivos. En este ejemplo, serverUrl
no es un valor reactivo, pero roomId
y message
sí lo son. Participan en el flujo de datos de renderizado:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
Valores reactivos como estos pueden cambiar debido a un re-renderizado. Por ejemplo, el usuario puede editar el message
o elegir un roomId
diferente en un desplegable. Los controladores de eventos y Efectos responden a los cambios de manera diferente:
- La lógica dentro de los controladores de eventos no es reactiva. No se ejecutará de nuevo a menos que el usuario vuelva a realizar la misma interacción (por ejemplo, un clic). Los controladores de eventos pueden leer valores reactivos sin «reaccionar» a sus cambios.
- La lógica dentro de los Efectos es reactiva. Si tu Efecto lee un valor reactivo, tienes que especificarlo como una dependencia Luego, si una nueva renderización hace que ese valor cambie, React volverá a ejecutar la lógica de tu Efecto con el nuevo valor.
Volvamos al ejemplo anterior para ilustrar esta diferencia.
La lógica dentro de los controladores de eventos no es reactiva
Echa un vistazo a esta línea de código. ¿Esta lógica debería ser reactiva o no?
// ...
sendMessage(message);
// ...
Desde la perspectiva del usuario, un cambio en el message
no significa que quiera enviar un mensaje. Solo significa que el usuario está escribiendo. En otras palabras, la lógica que envía un mensaje no debería ser reactiva. No debería volver a ejecutarse solo porque el valor reactivo ha cambiado. Por eso pertenece al controlador de evento:
function handleSendClick() {
sendMessage(message);
}
Los controladores de eventos no son reactivos, por lo que sendMessage(message)
solo se ejecutará cuando el usuario pulse el botón Enviar.
La lógica dentro de los Efectos es reactiva
Ahora volvamos a estas líneas:
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
Desde la perspectiva del usuario, un cambio en el roomId
significa que quieren conectarse a una sala diferente. En otras palabras, la lógica para conectarse a la sala debe ser reactiva. Usted quiere estas líneas de código para «mantenerse al día» con el valor reactivo, y para ejecutar de nuevo si ese valor es diferente. Es por eso que pertenece en un Efecto:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
Los Efectos son reactivos, por lo que createConnection(serverUrl, roomId)
y connection.connect()
se ejecutarán para cada valor distinto de roomId
. Tu Efecto mantiene la conexión de chat sincronizada con la sala seleccionada en ese momento.
Extraer lógica no reactiva fuera de los Efectos
Las cosas se vuelven más complicadas cuando tu quieres combinar lógica reactiva con lógina no reactiva.
Por ejemplo, imagina que quieres mostrar una notificación cuando el usuario se conecta al chat. Lees el tema actual (oscuro o claro) de los accesorios para poder mostrar la notificación en el color correcto:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('¡Conectado!', theme);
});
connection.connect();
// ...
Sin embargo, theme
es un valor reactivo (puede cambiar como resultado del re-renderizado), y cada valor reactivo leído por un Efecto debe ser declarado como su dependencia Ahora tienes que especificar theme
como una dependencia de tu Efecto:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('¡Conectado!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ Todas las dependencias declaradas
// ...
Juegue con este ejemplo y vea si puede detectar el problema con esta experiencia de usuario:
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('¡Conectado!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>¡Bienvenido a la sala {roomId}!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Escoje la sala de chat:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="viaje">viaje</option> <option value="música">música</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Usar tema oscuro </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
Cuando el roomId
cambia, el chat se reconecta como es de esperar. Pero como theme
también es una dependencia, el chat también se reconecta cada vez que cambias entre el tema oscuro y el claro. Esto no es bueno.
En otras palabras, no quieres que esta línea sea reactiva, aunque esté dentro de un Efecto (que es reactivo):
// ...
showNotification('¡Conectado!', theme);
// ...
Necesitas una forma de separar esta lógica no reactiva del Efecto reactivo que la rodea.
Declaración de un Evento de Efecto
Utiliza un Hook especial llamado useEffectEvent
para extraer esta lógica no reactiva de su Efecto:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('¡Conectado!', theme);
});
// ...
Aquí, onConnected
se llama un Evento de Efecto. Es una parte de tu lógica de Efecto, pero se comporta mucho más como un controlador de evento. La lógica dentro de él no es reactiva, y siempre «ve» los últimos valores de tus props y estado.
Ahora puedes llamar al Evento de Efecto onConnected
desde dentro de tu Efecto:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('¡Conectado!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Todas las dependencias declaradas
// ...
Esto resuelve el problema. Ten en cuenta que has tenido que eliminar onConnected
de la lista de dependencias de tu Efecto. Los Eventos de Efecto no son reactivos y deben ser omitidos de las dependencias.
Verifica que el nuevo comportamiento funciona como esperas:
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('¡Conectado!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>¡Bienvenido a la sala {roomId}!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Escoje la sala de chat:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="viaje">viaje</option> <option value="música">música</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Usar tema oscuro </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
Puedes pensar que los Eventos de Efecto son muy similares a los controladores de eventos. La principal diferencia es que los controladores de eventos se ejecutan en respuesta a las interacciones del usuario, mientras que los Eventos de Efecto son disparados por ti desde los Efectos. Los Eventos de Efecto te permiten «romper la cadena» entre la reactividad de los Efectos y el código que no debería ser reactivo.
Leer las últimas propiedades y el estado con los Eventos de Efecto
Los Eventos de Efecto le permiten arreglar muchos patrones en los que podría verse tentado a eliminar el linter de dependencias.
Por ejemplo, digamos que tienes un Efecto para registrar las visitas a la página:
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}
Más tarde, añades múltiples rutas a tu sitio. Ahora tu componente Page
recibe una propiedad url
con la ruta actual. Quieres pasar la url
como parte de tu llamada logVisit
, pero el linter de dependencias se queja:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 Hook de React useEffect tiene una dependencia que falta: 'url'
// ...
}
Piense en lo que quiere que haga el código. Usted quiere registrar una visita separada para diferentes URLs ya que cada URL representa una página diferente. En otras palabras, esta llamada a logVisit
debería ser reactiva con respecto a la url
. Por eso, en este caso, tiene sentido seguir el linter de dependencias, y añadir url
como dependencia:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ Todas las dependencias declaradas
// ...
}
Supongamos ahora que desea incluir el número de artículos en el carrito de compras junto con cada visita a la página:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}
Has utilizado numberOfItems
dentro del Efecto, por lo que el linter te pide que lo añadas como dependencia. Sin embargo, no quieres que la llamada a logVisit
sea reactiva con respecto a numberOfItems
. Si el usuario pone algo en el carro de la compra, y el numberOfItems
cambia, esto no significa que el usuario haya visitado la página de nuevo. En otras palabras, visitar la página es, en cierto sentido, un «evento». Ocurre en un momento preciso.
Divide el código en dos partes:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ Todas las dependencias declaradas
// ...
}
Aquí, onVisit
es un Evento de Efecto. El código que contiene no es reactivo. Por eso puedes usar numberOfItems
(¡o cualquier otro valor reactivo!) sin preocuparte de que cause que el código circundante se vuelva a ejecutar con los cambios.
Por otro lado, el Efecto en sí sigue siendo reactivo. El código dentro del Efecto utiliza la propiedad url
, por lo que el Efecto se volverá a ejecutar después de cada re-renderización con una url
diferente. Esto, a su vez, llamará al Evento de Efecto «onVisit».
Como resultado, se llamará a logVisit
por cada cambio en la url
, y siempre se leerá el último numberOfItems
. Sin embargo, si numberOfItems
cambia por sí mismo, esto no hará que se vuelva a ejecutar el código.
Profundizar
En las bases de código existentes, a veces puede ver la regla lint suprimida de esta manera:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Evite suprimir el linter de este modo:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
Después de que useEffectEvent
se convierta en una parte estable de React, recomendamos nunca suprimir el linter.
La primera desventaja de suprimir la regla es que React ya no te avisará cuando tu Efecto necesite «reaccionar» a una nueva dependencia reactiva que hayas introducido en tu código. En el ejemplo anterior, añadiste url
a las dependencias porque React te lo recordó. Si desactivas el linter, ya no recibirás esos recordatorios para futuras ediciones de ese Efecto. Esto conduce a errores.
Aquí hay un ejemplo de un error confuso causado por la supresión del linter. En este ejemplo, se supone que la función handleMove
lee el valor actual de la variable de estado canMove
para decidir si el punto debe seguir al cursor. Sin embargo, canMove
es siempre true
dentro de handleMove
.
¿Puedes ver por qué?
import { useState, useEffect } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); function handleMove(e) { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } } useEffect(() => { window.addEventListener('pointermove', handleMove); return () => window.removeEventListener('pointermove', handleMove); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> El punto puede moverse </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
El problema con este código está en suprimir el linter de dependencia. Si eliminas la supresión, verás que este Efecto debería depender de la función handleMove
. Esto tiene sentido: handleMove
se declara dentro del cuerpo del componente, lo que lo convierte en un valor reactivo. Cada valor reactivo debe ser especificado como una dependencia, ¡o puede potencialmente volverse obsoleto con el tiempo!
El autor del código original ha «mentido» a React diciendo que el Efecto no depende ([]
) de ningún valor reactivo. Por eso React no ha resincronizado el Efecto después de que canMove
haya cambiado (y handleMove
con él). Debido a que React no ha resincronizado el Efecto, el handleMove
adjunto como listener es la función handleMove
creada durante el render inicial. Durante el render inicial, canMove
era true
, por lo que handleMove
del render inicial verá siempre ese valor.
Si nunca suprimes el linter, nunca verás problemas con valores obsoletos.
Con useEffectEvent
, no hay necesidad de «mentir» al linter, y el código funciona como cabría esperar:
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); const onMove = useEffectEvent(e => { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } }); useEffect(() => { window.addEventListener('pointermove', onMove); return () => window.removeEventListener('pointermove', onMove); }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> El punto puede moverse </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
Esto no significa que useEffectEvent
sea siempre la solución correcta. Solo deberías aplicarlo a las líneas de código que no quieres que sean reactivas. En el sandbox anterior, no querías que el código del Efecto fuera reactivo con respecto a canMove
. Por eso tenía sentido extraer un Evento de Efecto.
Leer Eliminar dependencias de Efectos para otras alternativas correctas a la supresión del linter.
Limitaciones de los Eventos de Efecto
Los Eventos de Efecto tienen un uso muy limitado:
- Llámalos solo desde dentro Efectos.
- Nunca los pases a otros componentes o Hooks.
Por ejemplo, no declares y pases un Evento de Efecto así:
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 Evitar: Pasar Eventos de Efecto
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Necesitas especificar "callback" en las dependencias
}
En su lugar, declare siempre los Eventos de Efecto directamente junto a los Efectos que los utilizan:
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Bien: Solo se activa localmente dentro de un Efecto
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No es necesario especificar "onTick" (un evento de Efecto) como dependencia.
}
Los Eventos de Efecto son «piezas» no reactivas de tu código de Efecto. Deben estar junto al Efecto que los utiliza.
Recapitulación
- Los controladores de eventos se ejecutan en respuesta a interacciones específicas.
- Los Efectos se ejecutan siempre que es necesaria la sincronización.
- La lógica dentro de los controladores de eventos no es reactiva.
- La lógica dentro de Efectos es reactiva.
- Puede mover la lógica no reactiva de Efectos a Eventos de Efecto.
- Llame a Eventos de Efecto solo desde dentro de Efectos.
- No pase Eventos de Efecto a otros componentes o Hooks.
Desafío 1 de 4: Corregir una variable que no se actualiza
Este componente Timer
mantiene una variable de estado count
que se incrementa cada segundo. El valor por el que se incrementa se almacena en la variable de estado increment
. Puedes controlar la variable increment
con los botones más y menos.
Sin embargo, no importa cuántas veces haga clic en el botón más, el contador sigue incrementándose en uno cada segundo. ¿Qué pasa con este código? ¿Por qué increment
es siempre igual a 1
dentro del código del Efecto? Encuentra el error y arréglalo.
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <h1> Contador: {count} <button onClick={() => setCount(0)}>Reiniciar</button> </h1> <hr /> <p> Cada segundo, incrementar en: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }