Redux: hooks, performance & selectors

polbac
8 min readSep 20, 2019

TL;DR

Conclusiones a partir de useSelector(), mapStateToProps, reselect y tips de performance.

Nuestra app

Supongamos que tenemos una app en React con estos componentes:

<App>
<SelectedPerson />
<Team>
<Person />
<Person />
...
<Team />
</App>

Donde se visualiza un equipo de personas, y una de ellas está seleccionada en el primer componente.

También tendríamos un store con el estado de la aplicación:

const initState = {
selected: {
id: 4,
name: ‘alan’,
mail: ‘alan@email.com’,
image: ‘https://placeimg.com/640/481/animals'
},
people: [
{
id: 2,
name: ‘pablo’,
mail: ‘pablo@email.com’,
image: ‘https://placeimg.com/640/481/animals'
},
...
],

Componente <Team />

Nuestro componente principal, lo que debería hacer es generar una lista de <Person /> por cada uno de los items del store. Tiempo atrás había una única manera de hacer esto, pero como en el universo de JS todo avanza de manera muy rápida, hoy en día hay varias formas de hacerlo.

Lo primero que debemos hacer es conectar nuestro componente a redux:

connect()

Podemos utilizar connect() y pasarle la función mapStateToProps que lo que hace es tomar del store lo que el componente necesita. Esta función recibe como argumento el store total.

Hay distintas formas de hacer lo mismo (supongamos que también queremos saber la cantidad de elementos en el atributo total):

const mapStateToProps = (store) => ({
people: store.people,
total: store.people.length
})

Esta función devolverá un objeto con los dos atributos. Luego deberemos conectar nuestro componente:

class Team extends React.PureComponent {
render() {
const { people, total } = this.props
return <React.Fragment>
<div>
{people.map((person, index) =>
<Person key={index} data={person} />
)}
</div>
<p>Total: {total}</p>
</React.Fragment>
}
}
export default connect(mapStateToProps)(Team)

De esta manera cada vez que se actualice people dentro del store el componente se renderizará nuevamente. Cuando hablamos de actualizar people, necesitamos retornar una nueva versión del array, y no el mismo modificado, de lo contrario nuestros componentes no serán reactivos:

// por ejemplopeople.push(new People())
people: { ...people }
// opeople: {
...people,
[people.length]: new People()
}

Otra forma de hacer lo mismo es utilizando selectores:

export const selectPeople = store => store.people
export const selectTotalPeople = store => store.people.length
const mapStateToProps = (store) => ({
people: selectPeople(store),
total: selectTotalPeople(store)
})

Los selectores son funciones que las generamos para desacoplar la lógica de seleccionar elementos del store y reutilizarlos en distintos componentes.

Mientras el store de la aplicación vaya mutando nuestro componente no se actualizará (llamaremos actualizar a la ejecución del render()) mientras no se actualice el vector people.

Ahora bien, si dentro de nuestro mapStateToProps nosotros tenemos otro item que sí va variando, el render() se ejecutará.

Por ejemplo, si nosotros hacemos esto:

const mapStateToProps = store => ({
people: selectPeople(store),
total: selectTotalPeople(store),
random: Math.random()
})

Nuestro componente se ejecutará por más que people o total no varíen. Por esto, sería una mala práctica hacer esto mismo ya que la variación de random entrará en la comparación que hace redux. Si un componente necesita muchos elementos del store sería conveniente desarmarlo en distintos componentes.

Reselect

Una librería que pertenece a todo este mundo es reselect: permite componer y agregar memoization a nuestros selectors.

export const selectPeopleReselect = createSelector(
selectPeople,
people => people
)
const selectTotalPeopleReselect = createSelector(
selectPeopleReselect,
people => people.length
)
const selectPeopleWithTotal = createSelector(
selectPeopleReselect,
selectTotalPeopleReselect,
(people, total) => ({ people, total })
)
// y en el mapStateToProps
const mapStateToProps = store => selectPeopleWithTotal(store)

Con createSelector() podemos pasar como argumento todos los selectors (planos o reselectors) y luego una función que devolverá un objeto plano con los atributos > valores que queremos que devuelva.

Lo interesante es que si un selector está compuesto por otros selectores, reselect irá guardando el estado y haciendo memoize cuidando la performance.

Hooks

El último grito de la moda no escapó al mundo de redux y en vez de utilizar connect() tenemos el paliativo de hooks. Principalmente tenemos dos funciones useSelector() y useDispatch().

import { useSelector } from 'react-redux'export default () => {
const { people, total } = useSelector(state => ({
people: state.people,
total: state.people.length
}))
return <React.Fragment>
<div>
{people.map((person, index) =>
<Person key={index} data={person} />)}
</div>
<p>Total: {total}</p>
</React.Fragment>
}

De esta manera remplazamos mapDispatchToProps a useSelector(). Sólamente podemos utilizar los hooks en FunctionalComponents es decir que no podremos utilizar métodos de Lifecycle de React ni generar métodos dentro de nuestros componentes ya que no son clases.

❌ Nuestro componente por más que people o total no cambien, cada vez que se dispare una acción se renderizará, esto es porque no estamos haciendo una buena implementación de performance. Para resolver esto tenemos como mínimo dos formas:

✅ Usar varios useSelector (uno por cada elemento que queremos extraer)

const people = useSelector(selectPeople)
const total = useSelector(selectTotalPeople)

✅ Usar la librería reselect

✅ Hacer una función de comparación propia o utilizar shallowEqual

ShallowEqual

Por default useSelector hace una comparación === con la versión anterior del store para actualizar o no. En cambio connect() hace una comparación más profunda con mapState.

const refEquality = (a, b) => a === bexport function useSelector(selector, equalityFn = refEquality) {
...
}

Dicho esto, es necesario entender que useSelector se actualizará en todo momento si no tomamos los recaudos necesarios y podríamos tener problemas de performance. Para tener más control sobre este problema podemos pasarle a useSelector otra función que chequee si se hizo update y debemos volver a renderizar el componente.

Redux trae un helper, que hará una comparación más profunda y se implementa de la siguiente manera

import { shallowEqual, useSelector } from 'react-redux'const selectedData = useSelector(selectorReturningObject, shallowEqual)

La comparación que hace este helper es la siguiente:

export default function shallowEqual(objA, objB) {
if (is(objA, objB)) return true
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)

if (keysA.length !== keysB.length) return false
for (let i = 0; i < keysA.length; i++) {
if (
!hasOwn.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false
}
}

return true
}

También podemos hacer una función custom para validar si son iguales o no el estado anterior del siguiente, por ejemplo si nos interesa saber si la propiedad isActive varió lo haríamos de la siguiente manera:

const selectedData = useSelector(
selectorReturningObject,
(a, b) => a.active === b.active)
)

Performance Tools

Cuando hablamos de performance en nuestra aplicación react-redux, podemos dividirlo en los siguientes grupos:

  • Cuán pesada es la ejecución de un render() en un componente.
  • Si el render() realmente tiene que re-renderizarse o no.
  • Si el selector es muy pesado y por otro lado si realmente tiene que ejecutarse o podría estar memoizado.
  • Si podemos reutilizar código para consumir menos memoria.

Ahora, ¿cómo hacemos para saber si nuestros componentes se están renderizando cuando no deberían hacerlo?

El add-on de Chrome React Developer Tools posee una configuración que nos permite saber cuando un componente hizo update:

Primero debemos habilitarlo haciendo click en el ícono de configuración y activando Highlight Updates.

De esta manera cada vez que se actualiza un componente hará un destello alrededor del componente

Largas listas & Performance

Cuando necesitamos armar una vista que genera un listado seguramente estaremos utilizando un array y a partir de cada uno de los elementos del array montaremos un componente.

Cuando no tenemos muchos elementos que renderizar seguramente no tendremos problemas de performance. Pero si tenemos que renderizar cientos o miles de elementos, y los mismos deberían ir haciendo update, cada vez que nuestro array se modifique, ya sea enteramente o cambie sólo un elemento de nuestro array, toda la lista se generará de nuevo.

Por ejemplo, si tenemos este store:

const initialStore = {
movies: [
{ id: 1, title: 'Barbarella', active: false},
{ id: 2, title: 'La noche del terror ciego', active: false},
{ id: 3, title: 'The Rocky Horror Picture Show', active: false}
....
]
}

Seguramente tengamos dos componentes <List /> y otro de <Detail />

export const List = () => {
const movies = useSelector(state => state.movies)
return (
<React.Fragment>
{movies.map(m => <Detail movie={m} />)}
</React.Fragment>
)
}

Y Detail recibirá como props la información para renderizar la card de la película.

❌ Esta forma de hacer las cosas funcionará pero cada vez que cualquier elemento de cualquier item del array varíe, se volverá a renderizar la lista.

¿Cómo podemos hacer para resolver este problema? desnormalizar el store y trabajar sin arrays:

De esta forma podríamos tener el siguiente store:

const initialStore = {
moviesId: [ 1, 2, 3 ],
moviesById: {
1: { id: 1, title: 'Barbarella'},
2: { id: 2, title: 'La noche del terror ciego'},
3: { id: 3, title: 'The Rocky Horror Picture Show'}
....
}
}

Para hacer esto podríamos utilizar la funcionalidad _.groupBy() de lodash

Nuestros componentes tendrían otra forma:

export const List = () => {
const movies = useSelector(state => state.moviesId)
return (
<React.Fragment>
{movies.map(movieId => <Detail movieId={movieId} />)}
</React.Fragment>
)
}

Y el detail debería estar conectado con el el objeto con el detalle:

export const Detail = () => {
const movieDetail = useSelector(state =>
state.moviesById[props.movieId])
return (
<React.Fragment>
<h2/>{movieDetail.title}<h2/>
</React.Fragment>
)
}

¿Por qué de esta manera será más performante?

List no se volverá a renderizar a menos que agreguemos o eliminemos un id de dentro de su array.

Detail sólo se volverá a renderizar cuando cambie algo de su contenido, y únicamente el elemento que se modificó

Conclusiones

connect()

  • Es la forma vieja de acceder, pero si nuestro componente necesita métodos específicos deberíamos utilizarla ya que deberíamos usar una clase.
  • Utiliza una comparación de estados más profunda que useSelector (shallow equality).

useSelector()

  • Es la nueva forma de conectar nuestros componentes, se lee de una forma mucho más linda.
  • Utiliza una comparación a === b para chequear un re-render. De este modo podríamos tener problemas de performance.
  • Utilizar shallowEqual para comparaciones más profundas al primer nivel.
  • Utilizar métodos propios para saber si se actualizó o no el estado.
  • Utilizar multiples useSelector() por cada elemento que necesitamos del store.
  • Utilizar reselect para resolver el problema anterior.

reselect

  • Es una librería que permite componer selectors.
  • Nos ayuda a organizar nuestros selectores.
  • Aplica memoize y ayuda en la performance del re-render de nuestros componentes.

Performance

  • Utilizar la herramienta de React Developer Tools para saber si se re-renderiza o no un componente.
  • Des-normalizar el store para grandes listas.
  • Utilizar la propiedad key en los componentes de listados.

--

--