Reconciliación: cómo React decide qué reutilizar
¿Por qué mi input pierde lo que escribí cuando reordeno una lista?
Al terminar vas a poder explicar
- Explicar qué compara React exactamente al reconciliar
- Predecir cuándo se conserva y cuándo se pierde el estado de un componente
- Justificar por qué el índice del array casi nunca es una buena key
Comparar dos árboles y encontrar la mínima cantidad de operaciones para transformar uno en el otro es un problema resuelto: cuesta O(n³). Para mil nodos, mil millones de comparaciones. En cada tecla que apretás. No sirve.
React lo resuelve en O(n) haciendo trampa, y la trampa está muy bien elegida. Son dos suposiciones:
- Dos elementos de tipo distinto producen árboles distintos. No hace falta comparar adentro: se tira todo y se hace de nuevo.
- El programador puede marcar qué hijos son «el mismo» entre renders, con una key.
La segunda suposición es la que te delega a vos. La key no es un requisito burocrático para que React deje de tirar warnings: es información que sólo vos tenés y que el algoritmo necesita.
El algoritmo, en concreto
Cuando React reconcilia una lista de hijos, no compara todo contra todo. Hace esto:
- Recorre la lista vieja y la nueva en paralelo, mientras las keys coincidan. Es el caso feliz: nada se movió.
- Apenas una key deja de coincidir, corta. Si ya no quedan hijos nuevos, borra los viejos que sobran. Si ya no quedan viejos, crea los nuevos.
- Si quedan de los dos lados, arma un mapa por key con los viejos restantes. Después, para cada hijo nuevo, lo busca en el mapa. Si lo encuentra, reutiliza el fiber — con todo su estado adentro. Si no, crea uno nuevo.
- Lo que quedó en el mapa sin reclamar, se desmonta.
Y acá aparece el problema del índice
Cuando un hijo no tiene key, React lo indexa por su posición. Y usar el índice del array explícitamente como key es exactamente lo mismo: le estás diciendo a React que la identidad de ese elemento es su posición.
Si la lista nunca se reordena ni se filtra, no pasa nada. Si se reordena, le estás mintiendo: el ítem se movió, pero vos le dijiste a React que el de la posición 0 sigue siendo el de la posición 0.
1// La identidad es la POSICIÓN2{items.map((item, i) => <Fila key={i} item={item} />)}3 4// La identidad es el ÍTEM5{items.map((item) => <Fila key={item.id} item={item} />)}Corré las dos trazas de acá abajo. Es el mismo componente, con la misma interacción: se marca el primer ítem y después se reordena la lista. Lo único que cambia es la key.
Con el índice como key: la marca se queda en la posición
AGENDADOpaso 1 / 311Sin hooks en este paso.
Árbol de fibers
- <HostRoot>
Salida
Nada en el host todavía. React no commiteó ningún cambio.
createRoot().render()
Montaje inicial. No hay árbol previo, así que todo se va a crear desde cero.
VER EL CÓDIGO QUE SE ESTÁ TRAZANDO
1function Fila({ texto }) {2 const [marcado, setMarcado] = useState(false);3 return (4 <li onClick={() => setMarcado(true)}>5 {texto}{marcado ? " ✓" : ""}6 </li>7 );8}9 10function Lista() {11 const [items, setItems] = useState(["alfa", "beta", "gama"]);12 13 return (14 <div>15 <ul>16 {items.map((texto, i) => (17 <Fila key={i} texto={texto} /> // ← acá está la decisión18 ))}19 </ul>20 <button onClick={() => setItems(xs => [xs[2], xs[0], xs[1]])}>21 mover el último al principio22 </button>23 </div>24 );25}Con el foco acá: ← → un paso · espacio reproduce · Inicio/Fin van a los extremos.
Mirá la salida final: el ✓ quedó en gama, que ahora está en la posición 0. Pero vos habías marcado alfa. React reutilizó el fiber de la posición 0 — con su estado marcado = true — y le pasó props nuevas. El estado se quedó pegado al lugar, no al dato.
Con una key estable: el estado viaja con el elemento
AGENDADOpaso 1 / 312Sin hooks en este paso.
Árbol de fibers
- <HostRoot>
Salida
Nada en el host todavía. React no commiteó ningún cambio.
createRoot().render()
Montaje inicial. No hay árbol previo, así que todo se va a crear desde cero.
VER EL CÓDIGO QUE SE ESTÁ TRAZANDO
1function Fila({ texto }) {2 const [marcado, setMarcado] = useState(false);3 return (4 <li onClick={() => setMarcado(true)}>5 {texto}{marcado ? " ✓" : ""}6 </li>7 );8}9 10function Lista() {11 const [items, setItems] = useState(["alfa", "beta", "gama"]);12 13 return (14 <div>15 <ul>16 {items.map((texto, i) => (17 <Fila key={i} texto={texto} /> // ← acá está la decisión18 ))}19 </ul>20 <button onClick={() => setItems(xs => [xs[2], xs[0], xs[1]])}>21 mover el último al principio22 </button>23 </div>24 );25}Con el foco acá: ← → un paso · espacio reproduce · Inicio/Fin van a los extremos.
Ahora sí. Buscá en la traza el paso que dice reutilizado y movido: ese es React encontrando el fiber de alfa en el mapa por key, moviéndolo a su posición nueva y marcándolo con Placement para reubicar el nodo host. El estado viajó con él.
Consecuencias que ahora son obvias
- Cambiar el type destruye el estado. Pasar de
<div>a<span>, o de un componente a otro, tira el fiber entero y todo lo que tenía. No hay forma de conservarlo. - Una key distinta a propósito reinicia un componente. Si querés que un formulario se limpie al cambiar de usuario, ponele
key={usuarioId}. Es la forma idiomática de decir «este es otro». - Las keys sólo importan entre hermanos. No tienen que ser únicas en toda la app: sólo dentro del mismo array.
Antes de seguir · predecí
Tenés una lista de tareas con un checkbox en cada fila y usás key={index}. El usuario tilda la tercera y después borra la primera. ¿Qué ve?
1{tareas.map((tarea, i) => (2 <Fila key={i} tarea={tarea} />3))}Lo que te llevás
La reconciliación es O(n) porque React sólo compara el mismo nivel y confía en dos cosas: el type y la key. La key es la identidad que vos declarás. El estado vive en el fiber, así que conservar el fiber es conservar el estado. Todo lo demás es consecuencia.
Antes de marcarla, comprobá
- ¿Podés explicar explicar qué compara React exactamente al reconciliar?
- ¿Podés explicar predecir cuándo se conserva y cuándo se pierde el estado de un componente?
- ¿Podés explicar justificar por qué el índice del array casi nunca es una buena key?