Organízate con React
Conoce los principios básicos de uno de los frameworks más populares de la actualidad,en este taller realizarás un...
Aprende cómo usar Hooks en un desarrollo real de una aplicación de página única o SPA, y cómo obtener el máximo beneficio a la hora de utilizarlos.
En el post anterior sobre React Hooks hice una pequeña introducción a esta nueva característica introducida en las últimas versiones de esta librería y, del mismo modo, hice un pequeño repaso de cómo se ejecutaban tareas habituales del ciclo de vida de React con la nueva API de Hooks.
Ahora, y ya bien entrados en materia, con este artículo pretendo dar un vistazo más en profundidad sobre cómo usar Hooks en un desarrollo real a la hora de realizar una aplicación de página única o SPA, y sobre cómo podemos obtener el máximo beneficio a la hora de utilizar esta interesantísima adición.
La aplicación a la que me referiré en este artículo de aquí en adelante se encuentra en este repositorio, con una versión desplegada en este enlace.
Al comenzar este artículo me esforcé en encontrar una aplicación relativamente sencilla que pudiera ser una demostración fehaciente de la potencia de los hooks y que estuviera al límite (relativamente) del ámbito de aplicación para el que React sería recomendable a nivel de eficiencia. Así que, finalmente, decidí decantarme por un juego, pero no cualquier tipo de juego, sino por un juego incremental, es decir, un juego que se juega prácticamente él sólo y donde el jugador simplemente se encarga de optimizar el proceso por el que se obtiene cierta moneda virtual.
El desafío técnico de crear un juego incremental radica, en gran parte, en que existe un número continuo de llamadas a renderización, ya que la obtención de esta moneda virtual suele estar determinada tanto por los clicks del usuario como por una función que se ejecuta a cada segundo de juego. Este es, por tanto, un caso en el que el desarrollo javascript tradicional podría ofrecer una ventaja clara, ya que la renderización de cada elemento la haríamos bajo demanda y no sería necesario seguir un flujo de renderización fijo como en el caso de React (En el caso de querer hacerlo bajo sus normas).
Así que, con ello en mente, expondré el desarrollo en esta publicación de este juego de ejemplo, llamado RoboFactory, donde el objetivo será crear una próspera industria de robots que crean robots, y donde el objetivo final será obtener la mayor cantidad de dinero vendiéndolos.
A la hora de iniciar cualquier proyecto con React siempre suelo seguir un método personal, de forma secuencial e iterativa, que consta de cuatro pasos que considero realmente vitales: Definir la aplicación, Aislar funcionalidades, componentizar y distribuir responsabilidades.
El primer paso, y el más evidente, consiste en crear una pequeña definición para la aplicación que queremos desarrollar. Aunque la aplicación sea un proyecto personal y pensemos que no tenemos por qué seguir formalidades, algo tan trivial como definir la aplicación con unas cuantas frases nos permite empezar a crear una imagen mental de dónde nos encontramos, a donde queremos llegar y qué pasos y desafíos podemos encontrar por el camino.
En algunos casos, los más frecuentes, la definición de la aplicación nos vendrá dada, bien por un cliente, un superior o las propias necesidades del proyecto. Sin embargo, al encontrarnos ante un juego que hacemos por nuestra cuenta, podríamos dar una pequeña definición como la siguiente:
Robofactory es un juego en el que el objetivo es ensamblar robots para posteriormente venderlos y obtener la máxima ganancia posible.
Los robots sólo podrán ser vendidos en envíos de determinada cantidad, y la producción y venta se hará inicialmente clickando y más tarde automáticamente.
Existirá un personal que contrataremos y unas mejoras también adquiribles que mejorarán todas las estadísticas de nuestra fábrica como: Valor por robot, candidatos disponibles, número de unidades por envío, descuentos en contrataciones, calidad de candidatos…
Lo que valoro importante de una definición de este estilo, es que sea sencilla, fácil de estructurar y que condense en poco espacio una gran cantidad de información a tener en cuenta para su uso en los pasos posteriores.
Una de las claves para el éxito de una aplicación desarrollada en React es tener claro, y desde el principio, el cómo se pretende orientar el manejo del estado de la aplicación, y cual es el alcance y la sincronización necesaria entre partes disjuntas de la misma.
Para poder lograr esto el primer paso es aislar las funcionalidades de cada parte de nuestra app, y generalmente esto puede hacerse de múltiples maneras, aunque yo personalmente opto por dibujar un pequeño esquema de cada pantalla individual accesible por un usuario con una descripción general de las funcionalidades de cada una y de cada una de las partes que la componen. Como en este caso nos encontramos con una aplicación de página única (SPA) una posible esquematización simplificada de lo que buscamos sería la siguiente:
Gracias a este sencillo esquema ya podemos sacar varias conclusiones previas, útiles para los pasos subsiguientes:
Dada ya esta información ya podemos aislar las responsabilidades y, por tanto, el flujo de datos detallado de la aplicación.
En React, como en otras soluciones front-end, la componentización es tanto un paso necesario como una ventaja importante a la hora de crear nuestra aplicación. Pero además, en React, se hace especialmente importante pensar el cómo se va a realizar, ya que la cantidad de renderizados, y gran parte de la optimización y gestión del estado, dependerán de muchas de nuestras decisiones a la hora de convertir nuestra idea en un set de componentes relacionados.
Por ello inicialmente lo mejor es visualizar dichos componentes como un conjunto de cajas negras que aíslan funcionalidades, mientras distribuyen y procesan la información entre sí. Por ello, personalmente, tiendo a diseñar primero la interfaz en CSS o similar, aunque sea un mockup, para ayudarme a identificar de una manera práctica qué partes son candidatas a ser componentizadas.
Sin embargo, en este caso y de forma privilegiada, usaremos las imágenes finales de cada parte de la aplicación para ilustrar el proceso.
Encontrándonos ante una SPA, generalmente considero inteligente aislar el máximo de lógica, y el máximo de estado en un único componente, este tipo de componentes son conocidos como Componentes Contenedor, y son la contrapartida de los Componentes puros o Componentes presentacionales, que son aquellos que no disponen de estado y su renderización depende únicamente del cambio en sus propiedades.
Por tanto, considero un acierto diseñar esta aplicación en torno a un único componente contenedor, que contenga todo el estado necesario y sea el responsable de modificarlo a través de sus funciones, que introduciremos en el resto de componentes presentacionales de la aplicación a través de sus propiedades.
De esta manera (Y aquí me temo que tendréis que fiaros de mí) la aplicación será más controlable, más predecible, y por mucho que aumente en complejidad siempre sabremos cual es el flujo de la información en cualquier funcionalidad dada.
Componente principal (Main)
El panel de divisas no tendrá por qué ser necesariamente un componente, debido a su sencillez, pero sí estará compuesto por sendos componentes:
El panel de personal será también un componente individual que estará compuesto por una serie de componentes que contendrán a cada uno de los trabajadores, estos componentes tendrán una estructura común, serán claramente presentacionales, y la única funcionalidad de la que dispondrán (Que acepten drag&drop) les será dada por un callback que caerá en cascada desde el componente principal hasta el componente StaffCard desde el componente que los contiene.
El componente SuspenseImage será un sencillo componente con estado capaz de mostrar una imagen alternativa, o placeholder, mientras la imagen principal carga (Útil cuando esperamos tiempos de carga elevados en imágenes provenientes de servidor o de determinado servicio de terceros como es el caso aquí).
Este panel será un componente simple, que pudiéramos integrar incluso en el componente principal, que será capaz de gestionar un cambio de contenido dependiendo de la pestaña activa. En la primera pestaña renderizará un número de UpgradeComponents, también componentes puros que recibirán diversas propiedades para mostrar los textos y un callback para procesar la compra de la misma.
Y para la funcionalidad de renderización y generación de candidatos, tendremos un simple botón con un callback para la generación, y una serie de instancias de CandidateComponent en los que se mapeará cada objeto de la generación de candidatos, introduciéndolo en sus propiedades, con un callback que será llamado al intentar realizar un evento drag&drop hacia uno de los huecos disponibles en el panel de personal (O sea, intentar realizar una contratación).
Panel de divisas
Panel de personal
Otras funciones
Para que el juego tenga un cierto sentido, y quitando la parte orientada a optimizar la diversión (Muy importante en cualquier juego, pero fuera de alcance de este artículo), necesitaremos desarrollar un modelo básico y una serie de funcionalidades que integren la aplicación en sí.
Como bien comentamos, de la forma en la que hemos orientado la aplicación, estas responsabilidades recaen en el componente principal MainPage, algunas de las cuales son:
Por ello, para hablar del desarrollo usando Hooks nos centraremos de manera especial en el componente contenedor principal, el llamado MainPage, para luego mostrar a modo de ejemplo alguno de los componentes presentacionales y la resolución de otros problemas habituales.
A la hora de implementar el estado, y a diferencia de cómo lo hacíamos en un componente de clase, cada valor del estado utilizará su propio hook useState
. Sin embargo, para estados de valor numérico, también suelo recurrir adicionalmente a un objeto que represente los valores iniciales de cada estado de forma que sean fácilmente modificables en un mismo punto, para poder hacer ajustes posteriores. En este caso este objeto será:
const base = {
candidatesQuality: 0.5,
candidatesPrice: 10,
assemblyClickAmount: 1,
assemblySecAmount: 1,
minAssemblySell: 5,
cashPerRobot: 5,
candidatesNumber: 3
}
Y ahora, estos estados los introduciremos en la aplicación con valores iniciales referenciando a dicho objeto:
const [assemblyClickAmount, setAssemblyClickAmount] = useState(base.assemblyClickAmount);
const [assemblySecAmount, setAssemblySecAmount] = useState(base.assemblySecAmount);
const [minAssemblySell, setMinAssemblySell] = useState(base.minAssemblySell);
const [cashPerRobot, setCashPerRobot] = useState(base.cashPerRobot);
const [candidatesPrice, setCandidatesPrice] = useState(base.candidatesPrice);
const [candidatesQuality, setCandidatesQuality] = useState(base.candidatesQuality);
const [candidatesNumber, setCandidatesNumber] = useState(base.candidatesNumber);
La función de seteo de cada uno de estos estados podremos usarla más tarde de dos formas: Bien introduciendo un nuevo valor directamente, o bien utilizando una función que recibirá como parámetro el propio valor actual de dicho estado. Por ejemplo, para el callback para el botón de ensamblar:
const onClickAssemble = () => {
setAssembled(assembled => assembled + assemblyClickAmount);
}
De esta manera, usando esta función, la variable de estado assembled
aumentará un equivalente a la variable de estado assemblyClickAmount
respecto a su valor actual, por cada click en dicho botón.
El hook de efecto useEffect
, como comentábamos en el artículo anterior, tiene como objetivo gestionar los efectos secundarios de la aplicación, es decir, responder a los cambios en determinados valores del estado para poder ejecutar un código previo. Por tanto, este hook también sustituye a los antiguos métodos del ciclo de vida de los componentes de clase (componentDidMount
, componentDidUpdate
y componentWillUnmount
) y lo usaremos también para este propósito.
En nuestro caso, al montar el componente será necesario comprobar si existen datos previos guardados, esto en un componente de clase y utilizando localStorage
de la API de almacenamiento web y una función propia de carga loadData
, quedaría de la siguiente manera:
componentDidMount() {
if (localStorage.data) {
loadData(JSON.parse(localStorage.data));
}
}
Aquí simplemente podemos utilizar un hook useEffect
con su lista de dependencias vacía, que indicará que sólo debe de ejecutarse al montar el componente:
useEffect(() => {
if (localStorage.data) {
loadData(JSON.parse(localStorage.data));
}
}, [])
También, y como es un proceso razonablemente ligero, ejecutaremos el guardado en cada cambio del estado de la aplicación con una función propia saveData
. Esto, en el caso de un componente de clase, se realizaría con el método de ciclo de vida componentDidUpdate
:
componentDidUpdate() {
saveData();
}
En nuestro componente funcional esto se replica fácilmente introduciendo un nuevo hook useEffect
, pero esta vez sin lista de dependencias, lo cual indicará que debe de ejecutarse tras cada nuevo cambio de estado:
useEffect(() => {
saveData();
})
También es posible ejecutar el código sólo cuando han cambiado ciertos valores del estado de la aplicación, esto era posible realizarse en un componente de clase recibiendo el anterior estado y propiedades. Por ejemplo, para la funcionalidad de producción automática:
componentDidUpdate(prevProps, prevState) {
const { assemblySecAmount, isIndustrialActive, isLogisticsActive, cashPerRobot, minAssemblySell, assembled, cash } = this.state
// Hacemos una comprobación en el cambio de las variables de estado que nos interesen
if (prevState.assemblySecAmount !== assemblySecAmount ||
prevState.isIndustrialActive !== isIndustrialActive ||
prevState.isLogisticsActive !== isLogisticsActive ||
prevState.cashPerRobot !== cashPerRobot ||
prevState.minAssemblySell !== minAssemblySell) {
// Borramos el intervalo anterior, si existiera, antes de asignar uno nuevo.
clearInterval(this.interval);
this.interval = setInterval(() => {
if (isIndustrialActive && assemblySecAmount > 0) {
if (isLogisticsActive && assembled >= minAssemblySell) {
this.setState({
cash: cash + (minAssemblySell * cashPerRobot),
assembled: assembled + assemblySecAmount - minAssemblySell
})
} else {
this.setState({ assembled: assembled + assemblySecAmount })
}
}
}, 1000);
}
}
Esto se simplifica enormemente usando el hook useEffect
, ya que simplemente tendremos que colocar nuestra lógica y una lista de dependencias con los valores de estado que queremos evaluar para ejecutar nuestro efecto:
useEffect(() => {
const interval = setInterval(() => {
setAssembled(assembled => {
if (isIndustrialActive && assemblySecAmount > 0) {
if (isLogisticsActive && assembled >= minAssemblySell) {
setCash(cash => cash + (minAssemblySell * cashPerRobot));
return assembled + assemblySecAmount - minAssemblySell;
} else {
return assembled + assemblySecAmount;
}
}
return assembled;
})
}, 1000);
return () => clearInterval(interval);
}, [assemblySecAmount, isIndustrialActive, isLogisticsActive, cashPerRobot, minAssemblySell]);
A la hora de crear el interval hay que tener en cuenta que variables como cash
o assembled
que no forman parte de las dependencias del hook, como se copian por valor y no por referencia (A diferencia del state de los componentes de clase) , siempre tendrán el mismo valor, que será el equivalente al que tuviera en la creación de dicho interval. Por tanto, es por ello que se utiliza un setter con función, ya que la función del setter actúa como callback, y el valor que reciba como parámetro siempre estará correctamente actualizado.
Como bien habíamos comentado anteriormente, es acertado diseñar la aplicación siguiendo las recomendaciones de los propios creadores de React, esto es, con un componente superior dotado de estado y una cascada de props que se distribuyan entre el resto de componentes presentacionales.
Este tipo de diseño con flujo de datos descendente, tiene una serie de ventajas claras a la hora de simplificar nuestro desarrollo:
En el caso de que un componente interno deba de tener estado, simplemente nos aseguramos que este estado sólo afecte a otros componentes anidados a este, lo que aísla su funcionalidad en mayor o menor medida y contribuye también a la mejor gestión del estado de nuestra aplicación.
Un ejemplo en el proyecto de componente presentacional sería la tarjeta de candidato o CandidateComponent, que se encargará de recibir los datos de un candidato, pintarlos y gestionar los eventos de drag&drop. El componente funcional resultante es el siguiente:
import React from 'react';
import SuspenseImage from '../suspenseImage/suspenseImage';
import './candidateCard.css';
function CandidateCard(props) {
const { robot, onDragStart } = props;
return (
<div data-id={robot.id} className="candidate-card slide-bottom" draggable="true" onDragStart={onDragStart}>
<h2>
{ robot.name } { robot.surname }
</h2>
<SuspenseImage alt={robot.name} src={ robot.imageUrl } draggable="false"/>
<div className="body">
<div className="title">{ robot.specialization.title }</div>
<ul className="stats">
<li>
<span>R. resources</span>
<progress className="send-bar" value= {robot.stats.hrDirector.value}></progress>
</li>
<li>
<span>Robotting</span>
<progress className="send-bar" value={robot.stats.rChief.value}></progress>
</li>
<li>
<span>Selling</span>
<progress className="send-bar" value={robot.stats.ceo.value}></progress>
</li>
<li>
<span>Transport</span>
<progress className="send-bar" value={robot.stats.transport.value}></progress>
</li>
</ul>
<div className="cost">{ robot.cost }$</div>
</div>
</div>
)
}
export default CandidateCard;
Podemos observar como una vez recibido el robot, que es un modelo preacordado, simplemente se pintan cada una de sus propiedades de forma acorde. La razón por la que mandamos el modelo completo es sencilla, sólo consideramos una nueva renderización si el robot cambia, por tanto, no tiene sentido añadir más propiedades que quizás en otros casos fueran interesantes para controlar posibles cambios adicionales.
Otra propiedad que podemos observar en este componente es una función callback para gestionar el drag&drop de la tarjeta. Este callback se introduce en el componente principal, y lo único que hará será crear una propiedad en los datos del dataTransfer
del navegador:
const onDragCandidateCard = (event) => {
event.dataTransfer.setData("robotId", event.target.dataset.id);
}
Como hemos observado, pese a ser un callback no hemos usado el nuevo hook useCallback
, esto es intencional y es el contenido estructural en el que se basa la siguiente sección.
A la hora de optimizar, y no sólo con React, existe una regla de oro que debería de ser seguida siempre:
Primero programa, luego optimiza
Esta regla de oro se basa en un simple fundamento, y este es que en ocasiones las soluciones para optimizar un código introducen una complejidad extra que puede lograr todo lo contrario a lo que buscamos, es decir, o bien enlentezcan más nuestra aplicación o bien introduzca problemas de legibilidad no justificables por el aumento en rendimiento.
Y en React, y usando hooks, esto no es distinto. El uso de los hooks useMemo
y useCallback
crean funciones memorizadas que pueden ayudarnos a encapsular ciertos componentes o callbacks computacionalmente muy pesados para evitar que deban de ser re-renderizados o re-computados en cada renderización del componente que los contenga. Sin embargo, esto no viene sin un coste, ya que estos hooks introducen una complejidad adicional y pueden llegar a enlentecer nuestra aplicación.
Para elegir en qué casos usar uno de estos hooks esperaremos a terminar la aplicación, y una vez hecho esto, deberíamos de realizar un análisis del rendimiento del mismo. Para ello en este caso nos valdremos de las herramientas de desarrollador de React en Chrome, utilizando la función flamegraph del profiler. Para un intervalo de 30 segundos con todas las mejoras activadas, realizando 3 compras, 3 ventas, 1 cambio de pestaña, 3 generaciones de candidatos y 5 contrataciones, obtenemos los siguientes datos:
Donde podemos observar que, el tiempo total de renderizado ha sido 1,3 milisegundos, y el componente principal (Como podríamos esperar) ha ocupado 0,3 milisegundos de ese render. Como este componente incluye funciones costosas, intentaremos hacer una prueba de optimización con uno de ellos. Por ejemplo, lo aplicamos para el componente integrado en Main
que gestiona las pestañas:
const renderFunctionBar = React.useMemo(
() => {
let activeContent = null;
switch (activeTab) {
case 1:
activeContent = renderUpgrades;
break;
case 2:
activeContent = renderCandidates;
break;
default:
break;
}
return (
<div className="functions-bar slide-in-right">
<ul className="tabs" role="nav">
<li data-tab="1" className={activeTab === 1 ? "tab active" : "tab"} onClick={changeTab}>Upgrades</li>
{ upgrades.rr.active &&
<li data-tab="2" className={activeTab === 2 ? "tab active" : "tab"} onClick={changeTab}>Robot Resources</li>
}
</ul>
<div className="tab-content">
{ activeContent() }
</div>
</div>
)
}
, [activeTab, candidates, upgrades, cash, candidatesPrice, staff, candidatesQuality, candidatesNumber]);
El número de dependencias es muy elevado, eso no es una buena señal, ya que significará que habrá muchas condiciones bajo las cuales deberá de crearse un nuevo valor memorizado. Ejecutando el profiler de nuevo con las mismas condiciones:
Observamos que, efectivamente, el uso de useMemo
ha empeorado el rendimiento general de nuestra aplicación, aunque las renderizaciones mejor/peor son ligeramente más rápidas que su contrapartida, podemos observar en la gráfica temporal superior el alto nivel de picos en los tiempos de renderización, que anteriormente sólo ocurrían puntualmente y al ejecutarse alguna acción costosa (Como generar nuevos candidatos).
Otro posible campo de optimización es utilizar React.memo
para encapsular aquellos componentes puros, de forma que por ejmplo, para el caso de las Upgrades:
import React from 'react';
import './upgrade.css';
function Upgrade(props) {
return (
<div className={props.active ? "upgrade active" : "upgrade"}>
<div className="upgrade-img">
<img alt={props.name} src={props.image}/>
</div>
<div className="upgrade-body">
<div className="title">{props.name}</div>
<div className="price">{props.price}$</div>
<div className="desc">{props.desc}</div>
{ !props.active ?
<div className="tertiary btn btn-buy" onClick={props.onBuy}>Buy!</div>
:
<div className="tertiary btn btn-buy disabled">Already bought!</div>
}
</div>
</div>
)
}
export default React.memo(Upgrade);
De forma que mientras que las propiedades no cambien, las upgrades no volverán a renderizarse, y ello puede conllevar una optimización en el rendimiento. Ahora para la upgrade, realizando una prueba con 10 renderizaciones sin realizar acción alguna, en el caso sin memo
:
Y utilizando React.memo
como mostramos anteriormente:
Donde podemos observar que, en ambos casos, tanto la renderización del componente como la del componente principal (Encargado de renderizarlos todos) ha mejorado sensiblemente, y que por lo tanto hemos realizado una optimización exitosa.
Desarrollar con hooks puede ser una bendición, debido sobre todo al fino control que podemos ejercer sobre cada detalle de nuestra aplicación que quizás sería más difícil con frameworks como Angular, sin embargo, y como hemos visto, este también puede ser en muchas ocasiones un problema, ya que cada decisión que tomemos a la hora de implementar cualquier componente podrá tener una serie de consecuencias negativas: Mala legibilidad, descontrol del estado, problemas de optimización…etc.
Sin embargo, espero que este artículo haya podido resultar esclarecedor, o que al menos, haya sido una ventana a una forma de pensar orientada a la creación de aplicaciones, ya sea usando React o usando React con los recién introducidos Hooks.
Y si queréis saber más, y mejorar vuestros conocimientos aún más allá, no olvidéis echarle un vistazo a nuestro curso de React para principiantes y, si quieres algo en mayor profundidad y aplicación, a nuestro taller práctico sobre React Hooks.
También te puede interesar
Conoce los principios básicos de uno de los frameworks más populares de la actualidad,en este taller realizarás un...
Vue vs React: Comparamos los dos frameworks más utilizados en 2020, para que elijas el que más se adecue a tus necesidades...