
¿Has oído hablar de "levantar el estado"? Supongo que sí, y esa es la razón exacta por la que estás aquí. ¿Cómo podría ser posible que uno de los 12 conceptos principales enumerados en la documentación oficial de React conduzca a un bajo rendimiento? Dentro de este artículo, consideraremos una situación en la que es realmente el caso.
Paso 1: levántalo
Te sugiero que crees un juego simple de tres en raya. Para el juego necesitaremos:
Algún estado del juego. No hay lógica de juego real para saber si ganamos o perdemos. Solo una simple matriz bidimensional llena de undefined
, "x"
o "0".
const size = 10
Un contenedor principal para alojar el estado de nuestro juego.
const App = () => { const [field, setField] = useState(initialField) return ( <div> {field.map((row, rowI) => ( <div> {row.map((cell, cellI) => ( <Cell content={cell} setContent={ // Update a single cell of a two-dimensional array // and return a new two dimensional array (newContent) => setField([ // Copy rows before our target row ...field.slice(0, rowI), [ // Copy cells before our target cell ...field[rowI].slice(0, cellI), newContent, // Copy cells after our target cell ...field[rowI].slice(cellI + 1), ], // Copy rows after our target row ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> ) }
Un componente hijo para mostrar el estado de una sola celda.
const randomContent = () => (Math.random() > 0.5 ? 'x' : '0') const Cell = ({ content, setContent }) => ( <div onClick={() => setContent(randomContent())}>{content}</div> )
Demo en vivo # 1
Hasta ahora se ve bien. Un campo perfectamente reactivo con el que puedes interactuar a la velocidad de la luz :) Aumentemos el tamaño. Digamos, a 100. Sí, es hora de hacer clic en ese enlace de demostración y cambiar la variable de size
en la parte superior. ¿Todavía rápido para ti? Pruebe 200 o use la aceleración de la CPU integrada en Chrome . ¿Ve ahora un retraso significativo entre el momento en que hace clic en una celda y el momento en que cambia su contenido?
Cambiemos el size
a 10 y agreguemos algunos perfiles para investigar la causa.
const Cell = ({ content, setContent }) => { console.log('cell rendered') return <div onClick={() => setContent(randomContent())}>{content}</div> }
Demo en vivo # 2
Sí, eso es todo. console.log
simple sería suficiente ya que se ejecuta en cada render.
Entonces, ¿qué vemos? Según el número en las declaraciones de "celda representada" (para size
= N debería ser N) en nuestra consola, parece que todo el campo se vuelve a representar cada vez que cambia una celda.
Lo más obvio es agregar algunas claves como sugiere la documentación de React .
<div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} setContent={(newContent) => setField([ ...field.slice(0, rowI), [ ...field[rowI].slice(0, cellI), newContent, ...field[rowI].slice(cellI + 1), ], ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div>
Demo en vivo # 3
Sin embargo, después de aumentar el size
nuevamente, vemos que ese problema todavía está ahí. Si tan solo pudiéramos ver por qué se renderiza cualquier componente ... Afortunadamente, podemos hacerlo con la ayuda de los increíbles React DevTools . Es capaz de registrar por qué se procesan los componentes. Sin embargo, debe habilitarlo manualmente.

Una vez que está habilitado, podemos ver que todas las celdas se volvieron a representar porque sus accesorios cambiaron, específicamente, setContent
prop.

Cada celda tiene dos accesorios: content
y setContent
. Si la celda [0] [0] cambia, el contenido de la celda [0] [1] no cambia. Por otro lado, setContent
captura field
, cellI
y rowI
en su cierre. cellI
y rowI
permanecen igual, pero el field
cambia con cada cambio de cualquier celda.
Refactoricemos nuestro código y mantengamos setContent
igual.
Para mantener la referencia a setContent
igual, debemos deshacernos de los cierres. Podríamos eliminar el cierre de cellI
y rowI
haciendo que nuestra Cell
pase explícitamente cellI
y rowI
a setContent
. En cuanto al field
, podríamos utilizar una característica ordenada de setState
: acepta devoluciones de llamada .
const [field, setField] = useState(initialField)
Lo que hace que la App
vea así
<div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} rowI={rowI} cellI={cellI} setContent={setCell} /> ))} </div> ))} </div>
Ahora Cell
tiene que pasar cellI
y rowI
al setContent
.
const Cell = ({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) }
Demo en vivo # 4
Echemos un vistazo al informe DevTools.

Que? ¿Por qué diablos dice "los accesorios de los padres cambiaron"? Entonces, la cosa es que cada vez que se actualiza nuestro campo, la App
se vuelve a representar. Por lo tanto, sus componentes secundarios se vuelven a representar. Ok ¿Stackoverflow dice algo útil sobre la optimización del rendimiento de React? Internet sugiere usar shouldComponentUpdate
o sus parientes cercanos: PureComponent
y memo
.
const Cell = memo(({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) })
Demo en vivo # 5
Yay Ahora solo se vuelve a representar una celda una vez que cambia su contenido. Pero espera ... ¿Hubo alguna sorpresa? Seguimos las mejores prácticas y obtuvimos el resultado esperado.
Se suponía que una risa malvada estaba aquí. Como no estoy contigo, por favor, intenta lo más posible imaginarlo. Siga adelante y aumente el size
en la demostración en vivo # 5 . Esta vez es posible que tenga que ir con un número un poco mayor. Sin embargo, el retraso sigue ahí. ¿Por qué?
Echemos un vistazo al informe de DebTools nuevamente.

Solo hay un render de Cell
y fue bastante rápido, pero también hay un render de App
, que tomó bastante tiempo. La cuestión es que con cada nueva versión de la App
cada Cell
tiene que comparar sus nuevos accesorios con sus accesorios anteriores. Incluso si decide no renderizar (que es precisamente nuestro caso), esa comparación todavía lleva tiempo. O (1), pero ese O (1) ocurre size
* size
veces!
Paso 2: muévelo hacia abajo
¿Qué podemos hacer para solucionarlo? Si renderizar la App
nos cuesta demasiado, tenemos que dejar de renderizar la App
. No es posible si sigue alojando nuestro estado en la App
usando useState
, porque eso es exactamente lo que desencadena los renders. Entonces tenemos que mover nuestro estado hacia abajo y dejar que cada Cell
suscriba al estado por sí solo.
Creemos una clase dedicada que será un contenedor para nuestro estado.
class Field { constructor(fieldSize) { this.size = fieldSize
Entonces nuestra App
podría verse así:
const App = () => { return ( <div> {// As you can see we still need to iterate over our state to get indexes. field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> ) }
Y nuestra Cell
puede mostrar el contenido del field
por sí solo:
const Cell = ({ rowI, cellI }) => { console.log('cell render') const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) }
Demo en vivo # 6
En este punto, podemos ver cómo se representa nuestro campo. Sin embargo, si hacemos clic en una celda, no pasa nada. En los registros podemos ver "setCell" para cada clic, pero la celda permanece en blanco. La razón aquí es que nada le dice a la celda que vuelva a renderizar. Nuestro estado fuera de React cambia, pero React no lo sabe. Eso tiene que cambiar.
¿Cómo podemos activar un renderizado mediante programación?
Con las clases tenemos forceUpdate . ¿Significa que tenemos que volver a escribir nuestro código en las clases? En realidad no Lo que podemos hacer con los componentes funcionales es introducir un estado ficticio, que cambiamos solo para obligar a nuestro componente a volver a renderizar.
Así es como podemos crear un enlace personalizado para forzar los renders.
const useForceRender = () => { const [, setDummy] = useState(0) const forceRender = useCallback(() => setDummy((oldVal) => oldVal + 1), []) return forceRender }
Para activar una nueva representación cuando nuestro campo se actualiza, tenemos que saber cuándo se actualiza. Significa que tenemos que poder suscribirnos de alguna manera a las actualizaciones de campo.
class Field { constructor(fieldSize) { this.size = fieldSize this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) this.subscribers = {} } _cellSubscriberId(rowI, cellI) { return `row${rowI}cell${cellI}` } cellContent(rowI, cellI) { return this.data[rowI][cellI] } setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] const cellSubscriber = this.subscribers[this._cellSubscriberId(rowI, cellI)] if (cellSubscriber) { cellSubscriber() } } map(cb) { return this.data.map(cb) }
Ahora podemos suscribirnos a las actualizaciones de campo.
const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => field.subscribeCellUpdates(rowI, cellI, forceRender), [ forceRender, ]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) }
Demo en vivo # 7
Juguemos con el size
con esta implementación. Intente aumentarlo a los valores que antes se sentían rezagados. Y ... ¡es hora de abrir una buena botella de champán! ¡Tenemos una aplicación que representa una celda y una celda solo cuando cambia el estado de esa celda!
Echemos un vistazo al informe DevTools.

Como podemos ver ahora, solo se está procesando Cell
y es una locura rápida.
¿Qué pasa si digo que ahora el código de nuestra Cell
es una posible causa de una pérdida de memoria? Como puede ver, en useEffect
nos suscribimos a las actualizaciones de la celda, pero nunca nos damos de baja. Significa que incluso cuando Cell
se destruye, su suscripción continúa. Cambiemos eso.
Primero, debemos enseñarle a Field
lo que significa darse de baja.
class Field {
Ahora podemos aplicar unsubscribeCellUpdates
a nuestra Cell
.
const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) }
Demo en vivo # 8
Entonces, ¿cuál es la lección aquí? ¿Cuándo tiene sentido mover el estado hacia abajo del árbol de componentes? Nunca! Bueno, en realidad no :) Apéguese a las mejores prácticas hasta que fallen y no realice optimizaciones prematuras. Honestamente, el caso que consideramos anteriormente es algo específico, sin embargo, espero que lo recuerde si alguna vez necesita mostrar una lista realmente grande.
Paso adicional: refactorización del mundo real
En la demostración en vivo # 8 utilizamos el field
global, que no debería ser el caso en una aplicación del mundo real. Para resolverlo, podríamos alojar el field
en nuestra App
y pasarlo por el árbol usando [context] ().
const AppContext = createContext() const App = () => {
Ahora podemos consumir el field
del contexto en nuestra Cell
.
const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() const field = useContext(AppContext) useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) }
Demo en vivo # 9
Con suerte, has encontrado algo útil para tu proyecto. ¡No dudes en comunicarme tus comentarios! Aprecio mucho cualquier crítica y pregunta.