Conceptos básicos de Redux, Parte 6: Rendimiento, Normalización de Datos y Lógica Reactiva
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
- Cómo crear funciones selectoras memoizadas con
createSelector - Patrones para optimizar el rendimiento de renderizado de componentes
- Cómo usar
createEntityAdapterpara almacenar y actualizar datos normalizados - Cómo usar
createListenerMiddlewarepara lógica reactiva
- Completar la Parte 5 para entender el flujo de obtención de datos
Introducción
En la Parte 5: Lógica Asíncrona y Obtención de Datos, vimos cómo escribir thunks asíncronos para obtener datos de una API de servidor y patrones para manejar estados de carga en peticiones asíncronas.
En esta sección, veremos patrones optimizados para garantizar un buen rendimiento en nuestra aplicación, y técnicas para manejar automáticamente actualizaciones comunes de datos en el store. También veremos cómo escribir lógica reactiva que responda a acciones despachadas.
Hasta ahora, la mayor parte de nuestra funcionalidad se ha centrado en la característica de posts. Vamos a añadir un par de nuevas secciones a la aplicación. Después de añadirlas, examinaremos detalles específicos de cómo hemos construido las cosas y hablaremos sobre algunas debilidades de nuestra implementación actual y cómo podemos mejorarla.
Añadiendo más funcionalidades de usuario
Añadiendo páginas de usuario
Estamos obteniendo una lista de usuarios de nuestra API falsa, y podemos elegir un usuario como autor cuando añadimos una nueva publicación. Pero una aplicación de redes sociales necesita poder ver la página de un usuario específico y todas las publicaciones que ha hecho. Añadamos una página para mostrar la lista de todos los usuarios y otra para mostrar todas las publicaciones de un usuario específico.
Comenzaremos añadiendo un nuevo componente <UsersList>. Sigue el patrón habitual de leer datos del store con useSelector, y mapear el array para mostrar una lista de usuarios con enlaces a sus páginas individuales:
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
import { selectAllUsers } from './usersSlice'
export const UsersList = () => {
const users = useAppSelector(selectAllUsers)
const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))
return (
<section>
<h2>Users</h2>
<ul>{renderedUsers}</ul>
</section>
)
}
Y añadiremos una <UserPage>, que es similar a nuestra <SinglePostPage> en que toma un parámetro userId del router. Luego renderiza una lista de todas las publicaciones de ese usuario específico. Siguiendo nuestro patrón habitual, primero añadiremos un selector selectPostsByUser en postsSlice.ts:
// omit rest of the file
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ This seems suspicious! See more details below
return allPosts.filter(post => post.user === userId)
}
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
import { Link, useParams } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
import { selectPostsByUser } from '@/features/posts/postsSlice'
import { selectUserById } from './usersSlice'
export const UserPage = () => {
const { userId } = useParams()
const user = useAppSelector(state => selectUserById(state, userId!))
const postsForUser = useAppSelector(state =>
selectPostsByUser(state, userId!)
)
if (!user) {
return (
<section>
<h2>User not found!</h2>
</section>
)
}
const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))
return (
<section>
<h2>{user.name}</h2>
<ul>{postTitles}</ul>
</section>
)
}
Nota: estamos usando allPosts.filter() dentro de selectPostsByUser. ¡Este es un patrón incorrecto! Veremos por qué en un momento.
Ya tenemos los selectores selectAllUsers y selectUserById disponibles en nuestro usersSlice, así que podemos importarlos y usarlos directamente en los componentes.
Como hemos visto antes, podemos tomar datos de una llamada useSelector, o de props, y usarlos para decidir qué leer del store en otra llamada useSelector.
Como siempre, añadiremos rutas para estos componentes en <App>:
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
También añadiremos otra pestaña en <Navbar> que enlace a /users para poder hacer clic e ir a <UsersList>:
export const Navbar = () => {
// omit other logic
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
// omit other rendering
}
Ahora podemos navegar a la página de cada usuario y ver una lista exclusiva de sus publicaciones.
Enviando solicitudes de inicio de sesión al servidor
Actualmente, nuestro <LoginPage> y authSlice solo despachan acciones de Redux en el cliente para rastrear el nombre de usuario actual. En la práctica, necesitamos enviar una solicitud de inicio de sesión al servidor. Como hicimos con publicaciones y usuarios, convertiremos el manejo de inicio y cierre de sesión en thunks asíncronos.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
interface AuthState {
username: string | null
}
export const login = createAppAsyncThunk(
'auth/login',
async (username: string) => {
await client.post('/fakeApi/login', { username })
return username
}
)
export const logout = createAppAsyncThunk('auth/logout', async () => {
await client.post('/fakeApi/logout', {})
})
const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
// Remove the reducer definitions
reducers: {},
extraReducers: builder => {
// and handle the thunk actions instead
builder
.addCase(login.fulfilled, (state, action) => {
state.username = action.payload
})
.addCase(logout.fulfilled, state => {
state.username = null
})
}
})
// Removed the exported actions
export default authSlice.reducer
Junto con esto, actualizaremos <Navbar> y <LoginPage> para importar y despachar los nuevos thunks en lugar de los creadores de acciones anteriores:
import { Link } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { logout } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
import { UserIcon } from './UserIcon'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'
import { login } from './authSlice'
// omit types
export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()
const username = e.currentTarget.elements.username.value
await dispatch(login(username))
navigate('/posts')
}
Dado que el creador de acciones userLoggedOut se utilizaba en postsSlice, podemos actualizarlo para que escuche logout.fulfilled en su lugar:
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
// Import this thunk instead
import { logout } from '@/features/auth/authSlice'
// omit types and setup
const postsSlice = createSlice({
name,
initialState,
reducers: {
/* omitted */
},
extraReducers: builder => {
builder
// switch to handle the thunk fulfilled action
.addCase(logout.fulfilled, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// omit other cases
}
})
Añadiendo Notificaciones
Ninguna aplicación de redes sociales estaría completa sin algunas notificaciones que nos avisen cuando alguien envía un mensaje, deja un comentario o reacciona a una de nuestras publicaciones.
En una aplicación real, nuestro cliente mantendría comunicación constante con el servidor backend, y este enviaría actualizaciones al cliente cada vez que ocurre algo. Como esta es una aplicación de ejemplo, simularemos ese proceso añadiendo un botón para obtener entradas de notificaciones de nuestra API falsa. Tampoco tenemos otros usuarios reales enviando mensajes o reaccionando a publicaciones, así que la API falsa simplemente creará notificaciones aleatorias en cada solicitud. (Recuerda, el objetivo aquí es ver cómo usar Redux en sí mismo.)
Slice de Notificaciones
Como es una nueva parte de nuestra aplicación, el primer paso es crear un slice para las notificaciones y un thunk asíncrono para obtener entradas de notificación de la API. Para crear notificaciones realistas, incluiremos la marca de tiempo de la última notificación que tenemos en el estado. Esto permitirá que nuestro servidor simulado genere notificaciones más recientes.
import { createSlice } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
export interface ServerNotification {
id: string
date: string
message: string
user: string
}
export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
const allNotifications = selectAllNotifications(thunkApi.getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)
const initialState: ServerNotification[] = []
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export default notificationsSlice.reducer
export const selectAllNotifications = (state: RootState) => state.notifications
Como con los otros slices, importamos notificationsReducer en store.ts y lo añadimos a configureStore().
Hemos escrito un thunk asíncrono llamado fetchNotifications que obtendrá nuevas notificaciones del servidor. Queremos usar la marca de tiempo de creación de la notificación más reciente como parte de la solicitud, para que el servidor solo envíe notificaciones realmente nuevas.
Sabemos que recibiremos un array de notificaciones, así que podemos pasarlas como argumentos separados a state.push(), y el array añadirá cada elemento. También queremos ordenarlas para que la notificación más reciente sea la primera, en caso de que el servidor las envíe desordenadas. (Recordatorio: array.sort() siempre muta el array existente, algo seguro solo porque usamos createSlice e Immer internamente.)
Argumentos de los Thunks
Si observas nuestro thunk fetchNotifications, contiene algo nuevo. Hablemos un momento sobre los argumentos de los thunks.
Ya hemos visto que podemos pasar un argumento al creador de acciones thunk al despacharlo, como dispatch(addPost(newPost)). Para createAsyncThunk específicamente, solo puedes pasar un argumento, que se convierte en el primer parámetro del callback de creación del payload. Si no pasamos nada, ese argumento será undefined.
El segundo argumento de nuestro creador de payload es un objeto thunkAPI con funciones e información útil:
-
dispatchygetState: los métodos realesdispatchygetStatede nuestro store Redux. Puedes usarlos dentro del thunk para despachar acciones adicionales u obtener el estado actualizado (por ejemplo, leer un valor actualizado tras despachar otra acción). -
extra: el "argumento extra" que puede pasarse al middleware thunk al crear el store. Suele ser un wrapper de API con funciones para hacer llamadas al servidor, evitando incluir URLs y lógica de consulta directamente en los thunks. -
requestId: un ID único aleatorio para esta llamada thunk. Útil para rastrear el estado de solicitudes individuales. -
signal: FunciónAbortController.signalpara cancelar solicitudes en progreso. -
rejectWithValue: utilidad que ayuda a personalizar el contenido de una acciónrejectedsi el thunk recibe un error.
(Si escribes un thunk manualmente en lugar de usar createAsyncThunk, la función thunk recibirá (dispatch, getState) como argumentos separados, no agrupados en un objeto.)
Para más detalles sobre estos argumentos y cómo manejar la cancelación de thunks y peticiones, consulta la página de referencia de la API de createAsyncThunk.
En este caso, necesitamos acceder al argumento thunkApi, que siempre es el segundo. Esto significa que debemos proporcionar algún nombre de variable para el primer argumento, aunque no le pasemos nada al despachar el thunk y no necesitemos usarlo dentro de la función de carga útil. Así que simplemente le daremos el nombre _unused.
A partir de ahí, sabemos que la lista de notificaciones está en el estado de nuestra store Redux, y que la notificación más reciente debería ser la primera del array. Podemos llamar a thunkApi.getState() para leer el valor del estado y usar el selector selectAllNotifications para obtener solo el array de notificaciones. Como el array de notificaciones está ordenado con las más recientes primero, podemos obtener la última usando desestructuración de arrays.
Añadiendo la Lista de Notificaciones
Ahora que tenemos creado el notificationsSlice, podemos añadir un componente <NotificationsList>. Necesita leer la lista de notificaciones de la store y formatearlas, incluyendo mostrar cuán reciente es cada notificación y quién la envió. Ya tenemos los componentes <PostAuthor> y <TimeAgo> que pueden hacer ese formateo, así que podemos reutilizarlos aquí. Dicho esto, <PostAuthor> incluye un prefijo "por " que no tiene sentido aquí: modificaremos el componente para añadir una prop showPrefix que por defecto sea true, y específicamente no mostraremos prefijos aquí.
interface PostAuthorProps {
userId: string
showPrefix?: boolean
}
export const PostAuthor = ({ userId, showPrefix = true }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))
return (
<span>
{showPrefix ? 'by ' : null}
{author?.name ?? 'Unknown author'}
</span>
)
}
import { useAppSelector } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from '@/features/posts/PostAuthor'
import { selectAllNotifications } from './notificationsSlice'
export const NotificationsList = () => {
const notifications = useAppSelector(selectAllNotifications)
const renderedNotifications = notifications.map(notification => {
return (
<div key={notification.id} className="notification">
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
También necesitamos actualizar el <Navbar> para añadir una pestaña de "Notifications" y un nuevo botón para obtener notificaciones:
// omit several imports
import { logout } from '@/features/auth/authSlice'
import { fetchNotifications } from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit user details */}
</div>
)
}
// omit other rendering
}
Por último, necesitamos actualizar App.tsx con la ruta de "Notificaciones" para poder navegar a ella:
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'
function App() {
return (
// omit all the outer router setup
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
<Route path="/notifications" element={<NotificationsList />} />
</Routes>
)
}
Así se ve la pestaña de "Notifications" por ahora:

Mostrando Notificaciones Nuevas
Cada vez que hacemos clic en "Refresh Notifications", se añadirán algunas entradas más a nuestra lista. En una aplicación real, estas podrían llegar del servicio mientras miramos otras partes de la UI. Podemos simular algo similar haciendo clic en "Refresh Notifications" mientras vemos el <PostsList> o <UserPage>.
Pero ahora mismo no tenemos idea de cuántas notificaciones acaban de llegar, y si seguimos haciendo clic en el botón, podrían acumularse muchas que aún no hemos leído. Añadamos lógica para llevar un registro de qué notificaciones se han leído y cuáles son "nuevas". Esto nos permitirá mostrar el recuento de notificaciones "no leídas" como insignia en nuestra pestaña de "Notifications" en el navbar, y mostrar nuevas notificaciones en otro color.
Rastreando el Estado de las Notificaciones
Los objetos Notification que nuestra API falsa envía son del tipo {id, date, message, user}. El concepto de "nuevo" o "no leído" solo existirá en el cliente. Dado esto, modifiquemos el notificationsSlice para soportarlo.
Primero, crearemos un nuevo tipo ClientNotification que extienda ServerNotification para añadir esos dos campos. Luego, cuando recibamos un nuevo lote de notificaciones del servidor, siempre añadiremos estos campos con valores predeterminados.
A continuación, añadiremos un reducer que marque todas las notificaciones como leídas, y algo de lógica para gestionar el marcado de notificaciones existentes como "no nuevas".
Finalmente, también podemos añadir un selector que cuente cuántas notificaciones no leídas hay en la store:
// omit imports
export interface ServerNotification {
id: string
date: string
message: string
user: string
}
export interface ClientNotification extends ServerNotification {
read: boolean
isNew: boolean
}
// omit thunk
const initialState: ClientNotification[] = []
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))
state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
state.push(...notificationsWithMetadata)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Marcando Notificaciones como Leídas
Queremos marcar estas notificaciones como leídas cada vez que nuestro componente <NotificationsList> se renderice, ya sea porque hicimos clic en la pestaña para ver las notificaciones o porque ya estábamos en ella y recibimos nuevas notificaciones. Podemos lograrlo despachando allNotificationsRead cada vez que este componente se vuelva a renderizar. Para evitar parpadeos de datos antiguos durante la actualización, despacharemos la acción en un hook useLayoutEffect. También queremos añadir una clase adicional a cada entrada de la lista de notificaciones para resaltarlas:
import { useLayoutEffect } from 'react'
import classnames from 'classnames'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from '@/features/posts/PostAuthor'
import {
allNotificationsRead,
selectAllNotifications
} from './notificationsSlice'
export const NotificationsList = () => {
const dispatch = useAppDispatch()
const notifications = useAppSelector(selectAllNotifications)
useLayoutEffect(() => {
dispatch(allNotificationsRead())
})
const renderedNotifications = notifications.map(notification => {
const notificationClassname = classnames('notification', {
new: notification.isNew
})
return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
Esto funciona, pero tiene un comportamiento un tanto sorprendente. Cada vez que hay notificaciones nuevas (ya sea porque acabamos de cambiar a esta pestaña o porque hemos obtenido nuevas notificaciones de la API), verás que se despachan dos acciones "notifications/allNotificationsRead". ¿Por qué ocurre esto?
Supongamos que hemos obtenido algunas notificaciones mientras mirábamos el <PostsList> y luego hacemos clic en la pestaña "Notificaciones". El componente <NotificationsList> se montará, y la función de useLayoutEffect se ejecutará después del primer renderizado y despachará allNotificationsRead. Nuestro notificationsSlice manejará esto actualizando las entradas de notificaciones en el store. Esto crea un nuevo array state.notifications con las entradas actualizadas de forma inmutable, lo que fuerza a nuestro componente a renderizar nuevamente porque detecta un nuevo array devuelto por useSelector.
Cuando el componente se renderiza por segunda vez, el hook useLayoutEffect se ejecuta de nuevo y despacha allNotificationsRead otra vez. El reducer también se ejecuta de nuevo, pero esta vez no hay cambios en los datos, por lo que el estado del slice y el estado global permanecen iguales, y el componente no se vuelve a renderizar.
Hay un par de formas de evitar ese segundo despacho, como dividir la lógica para despachar solo al montar el componente y volver a despachar solo si cambia el tamaño del array de notificaciones. Pero como esto no causa ningún problema real, podemos dejarlo así.
Esto demuestra que es posible despachar una acción sin que ocurra ningún cambio de estado. Recuerda que siempre es responsabilidad de tus reducers decidir si el estado necesita actualizarse realmente, y "no hacer nada" es una decisión válida para un reducer.
Así se ve la pestaña de notificaciones ahora que tenemos funcionando el comportamiento de "nuevas/leídas":

Mostrar notificaciones no leídas
Lo último que debemos hacer antes de continuar es añadir un badge en nuestra pestaña "Notificaciones" de la barra de navegación. Esto mostrará el recuento de notificaciones "No leídas" cuando estemos en otras pestañas:
// omit other imports
import {
fetchNotifications,
selectUnreadNotificationsCount
} from '@/features/notifications/notificationsSlice'
export const Navbar = () => {
const dispatch = useAppDispatch()
const username = useAppSelector(selectCurrentUsername)
const user = useAppSelector(selectCurrentUser)
const numUnreadNotifications = useAppSelector(selectUnreadNotificationsCount)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}
let unreadNotificationsBadge: React.ReactNode | undefined
if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit button */}
</div>
)
}
// omit other rendering
}
Mejorar el rendimiento de renderizado
Nuestra aplicación parece útil, pero en realidad tenemos un par de problemas en cuándo y cómo se vuelven a renderizar nuestros componentes. Veamos estos problemas y hablemos de formas de mejorar el rendimiento.
Investigar el comportamiento de renderizado
Podemos usar el Profiler de React DevTools para ver gráficos de qué componentes se vuelven a renderizar cuando se actualiza el estado. Intenta navegar a la <UserPage> de un usuario individual. Abre las DevTools de tu navegador y, en la pestaña "Profiler" de React, haz clic en el botón circular "Record" en la esquina superior izquierda. Luego, haz clic en el botón "Refresh Notifications" en nuestra aplicación y detén la grabación en el Profiler de React DevTools. Deberías ver un gráfico como este:

Vemos que el <Navbar> se volvió a renderizar, lo cual tiene sentido porque debía mostrar el badge actualizado de "notificaciones no leídas" en la pestaña. Pero, ¿por qué se volvió a renderizar nuestra <UserPage>?
Si inspeccionamos las últimas acciones despachadas en Redux DevTools, vemos que solo se actualizó el estado de notificaciones. Como la <UserPage> no lee ninguna notificación, no debería haberse vuelto a renderizar. Algo debe estar mal en el componente o en uno de los selectores que está utilizando.
<UserPage> está leyendo la lista de publicaciones del store mediante selectPostsByUser. Si observamos selectPostsByUser con atención, hay un problema específico:
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ WRONG - this _always_ creates a new array reference!
return allPosts.filter(post => post.user === userId)
}
Sabemos que useSelector se volverá a ejecutar cada vez que se despache una acción, y que fuerza al componente a volver a renderizarse si devolvemos un nuevo valor de referencia.
Estamos llamando a filter() dentro de la función del selector, de modo que solo devolvemos la lista de publicaciones que pertenecen a este usuario.
Desafortunadamente, esto significa que useSelector siempre devuelve una nueva referencia de array para este selector, ¡y por tanto nuestro componente se volverá a renderizar tras cada acción incluso si los datos de las publicaciones no han cambiado!.
Este es un error común en aplicaciones Redux. Debido a ello, React-Redux realiza comprobaciones en modo desarrollo para detectar selectores que accidentalmente devuelven siempre nuevas referencias. Si abres las herramientas de desarrollo del navegador y vas a la consola, deberías ver una advertencia que dice:
Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized:
at UserPage (http://localhost:5173/src/features/users/UserPage.tsx)
En la mayoría de los casos, el error nos diría el nombre real de la variable del selector. En este caso, el mensaje de error no tiene un nombre específico para el selector, porque estamos usando una función anónima dentro de useAppSelector. Pero, saber que está en <UserPage> nos ayuda a identificarlo.
Ahora, de forma realista esto no es un problema de rendimiento significativo en esta aplicación de ejemplo. El componente <UserPage> es pequeño y no se despachan muchas acciones en la aplicación. Sin embargo, esto puede ser un problema de rendimiento muy importante en aplicaciones reales, con un impacto que varía según la estructura de la aplicación. Dado eso, que componentes adicionales se vuelvan a renderizar cuando no necesitaban hacerlo es un problema de rendimiento común y algo que deberíamos intentar solucionar.
Memoizar funciones selectoras
Lo que realmente necesitamos es una forma de calcular el nuevo array filtrado solo si state.posts o userId han cambiado. Si no han cambiado, queremos devolver la misma referencia del array filtrado que la última vez.
Esta idea se llama "memoización". Queremos guardar un conjunto previo de entradas y el resultado calculado, y si las entradas son las mismas, devolver el resultado anterior en lugar de recalcularlo de nuevo.
Hasta ahora, hemos estado escribiendo selectores por nuestra cuenta como funciones simples, y usándolos principalmente para no tener que copiar y pegar el código para leer datos del store. Sería estupendo si hubiera una forma de hacer que nuestras funciones selectoras estuvieran memoizadas para poder mejorar el rendimiento.
Reselect es una biblioteca para crear funciones selectoras memoizadas, y fue diseñada específicamente para usarse con Redux. Tiene una función createSelector que genera selectores memoizados que solo volverán a calcular los resultados cuando las entradas cambien. Redux Toolkit exporta la función createSelector, así que ya la tenemos disponible.
Reescribamos selectPostsByUser como una función memoizada con createSelector:
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'
// omit slice logic
export const selectAllPosts = (state: RootState) => state.posts.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsByUser = createSelector(
// Pass in one or more "input selectors"
[
// we can pass in an existing selector function that
// reads something from the root `state` and returns it
selectAllPosts,
// and another function that extracts one of the arguments
// and passes that onward
(state: RootState, userId: string) => userId
],
// the output function gets those values as its arguments,
// and will run when either input value changes
(posts, userId) => posts.filter(post => post.user === userId)
)
createSelector primero necesita una o más funciones "selectoras de entrada" (ya sea juntas dentro de un solo array, o como argumentos separados). También necesitas pasar una "función de salida", que calcula el resultado.
Cuando llamamos a selectPostsByUser(state, userId), createSelector pasará todos los argumentos a cada uno de nuestros selectores de entrada. Lo que sea que devuelvan esos selectores de entrada se convierte en los argumentos para el selector de salida. (Ya hemos hecho algo similar en selectCurrentUser, donde primero llamamos a const currentUsername = selectCurrentUsername(state).)
En este caso, sabemos que necesitamos el array de todas las publicaciones y el ID de usuario como los dos argumentos para nuestro selector de salida. Podemos reutilizar nuestro selector existente selectAllPosts para extraer el array de publicaciones. Dado que el ID de usuario es el segundo argumento que pasamos a selectPostsByUser, podemos escribir un pequeño selector que simplemente devuelva userId.
Nuestra función de salida recibe entonces posts y userId como argumentos, y devuelve el array filtrado de publicaciones solo para ese usuario.
Si intentamos llamar a selectPostsByUser varias veces, solo se volverá a ejecutar el selector de salida si posts o userId han cambiado:
const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')
dispatch(fetchUsers())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')
// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')
Ahora que hemos memoizado selectPostsByUser, podemos repetir la prueba con el perfilador de React mientras <UserPage> está abierto y se están obteniendo notificaciones. Esta vez deberíamos ver que <UserPage> no se vuelve a renderizar:

Equilibrio en el uso de selectores
Los selectores memoizados son herramientas valiosas para mejorar el rendimiento en aplicaciones React+Redux, ya que ayudan a evitar renderizados innecesarios y cálculos complejos cuando los datos de entrada no cambian.
¡No todos los selectores necesitan memoización! La mayoría que hemos usado son funciones simples que funcionan bien. Solo se requiere memoización cuando:
- Se crean y devuelven nuevas referencias de objetos/arrays
- La lógica de cálculo es "costosa"
Como ejemplo, revisemos selectUnreadNotificationsCount:
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Este selector es una función simple que usa .filter() internamente. Pero observa que no devuelve un nuevo array, sino un número. Esto es más seguro: aunque se actualice el array de notificaciones, el valor devuelto no cambiará constantemente.
Re-filtrar el array cada vez tiene cierto costo. Sería razonable memoizarlo para ahorrar ciclos de CPU, pero no es tan crítico como cuando se devuelven nuevas referencias.
Para más detalles sobre selectores y memoización con Reselect:
Analizando la lista de publicaciones
Si en <PostsList> hacemos clic en un botón de reacción mientras capturamos un perfilado, veremos que no solo se renderizan <PostsList> y el <PostExcerpt> actualizado, sino todos los componentes <PostExcerpt>:

¿Por qué sucede esto? Si ninguna otra publicación cambió, ¿por qué necesitarían renderizarse?
¡El comportamiento predeterminado de React es renderizar recursivamente todos los componentes hijos cuando un padre se renderiza!. La actualización inmutable de un post creó un nuevo array posts. Nuestro <PostsList> tuvo que volver a renderizarse porque el array posts era una nueva referencia, así que después de que se renderizó, React continuó hacia abajo y volvió a renderizar todos los componentes <PostExcerpt>.
En nuestra app pequeña no es crítico, pero en aplicaciones reales con listas largas o árboles complejos, estos renderizados adicionales pueden ralentizar la experiencia.
Opciones para optimizar listas
Existen varias formas de optimizar este comportamiento en <PostsList>:
Primero: envolver <PostExcerpt> en React.memo(), que evita renderizados si las props no cambian. Funciona bien:
let PostExcerpt = ({ post }: PostExcerptProps) => {
// omit logic
}
PostExcerpt = React.memo(PostExcerpt)
Otra opción es reescribir <PostsList> para que solo seleccione un array de IDs de publicaciones del store, en lugar del array completo de posts, y modificar <PostExcerpt> para que reciba una prop postId y llame a useSelector para leer el objeto de publicación que necesita. Si <PostsList> obtiene la misma lista de IDs que antes, no necesitará volver a renderizarse, por lo que solo nuestro componente modificado <PostExcerpt> debería renderizarse.
Desafortunadamente, esto se complica porque también necesitamos tener todas nuestras publicaciones ordenadas por fecha y renderizadas en el orden correcto. Podríamos actualizar nuestro postsSlice para mantener el array ordenado en todo momento, evitando ordenarlo en el componente, y usar un selector memoizado para extraer solo la lista de IDs de publicaciones. También podríamos personalizar la función de comparación que useSelector ejecuta para verificar los resultados, como useSelector(selectPostIds, shallowEqual), para omitir el re-renderizado si el contenido del array de IDs no ha cambiado.
La última opción es encontrar alguna forma de que nuestro reducer mantenga un array separado de IDs para todas las publicaciones, modificando ese array solo cuando se añaden o eliminan posts, y reescribir <PostsList> y <PostExcerpt> de manera similar. Así, <PostsList> solo necesitará volver a renderizarse cuando cambie ese array de IDs.
Afortunadamente, Redux Toolkit tiene una función createEntityAdapter que nos ayudará precisamente con eso.
Normalizando datos
Has visto que gran parte de nuestra lógica busca elementos por su campo ID. Como hemos almacenado nuestros datos en arrays, eso significa que tenemos que recorrer todos los elementos del array usando array.find() hasta encontrar el elemento con el ID que buscamos.
Realmente, esto no lleva mucho tiempo, pero si tuviéramos arrays con cientos o miles de elementos, revisar todo el array para encontrar un elemento se convierte en un esfuerzo desperdiciado. Lo que necesitamos es una forma de buscar un único elemento directamente por su ID, sin tener que comprobar todos los demás elementos. Este proceso se conoce como "normalización".
Estructura de estado normalizado
"Estado normalizado" significa que:
-
Solo tenemos una copia de cada dato concreto en nuestro estado, evitando duplicaciones
-
Los datos normalizados se mantienen en una tabla de búsqueda, donde los IDs de los elementos son las claves y los elementos mismos son los valores. Esto suele ser un simple objeto de JS.
-
También puede existir un array con todos los IDs para un tipo de elemento concreto
Los objetos de JavaScript pueden usarse como tablas de búsqueda, similares a los "maps" o "diccionarios" en otros lenguajes. Así podría verse el estado normalizado para un grupo de objetos user:
{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}
Esto facilita encontrar un objeto user específico por su ID, sin tener que recorrer todos los demás objetos de usuario en un array:
const userId = 'user2'
const userObject = state.users.entities[userId]
Para más detalles sobre por qué es útil normalizar el estado, consulta Normalizando la forma del estado y la sección de la Guía de uso de Redux Toolkit sobre Gestión de datos normalizados.
Gestión del estado normalizado con createEntityAdapter
La API createEntityAdapter de Redux Toolkit proporciona una forma estandarizada de almacenar tus datos en un slice tomando una colección de elementos y dándoles la forma { ids: [], entities: {} }. Junto con esta estructura de estado predefinida, genera un conjunto de funciones reductoras y selectores que saben cómo trabajar con esos datos.
Esto tiene varios beneficios:
-
No tenemos que escribir nosotros mismos el código para gestionar la normalización
-
Las funciones reductoras predefinidas de
createEntityAdaptermanejan casos comunes como "añadir todos estos elementos", "actualizar un elemento" o "eliminar varios elementos" -
createEntityAdapterpuede mantener opcionalmente el array de IDs en un orden clasificado basado en el contenido de los elementos, y solo actualizará ese array si se añaden/eliminan elementos o cambia el criterio de ordenación.
createEntityAdapter acepta un objeto de opciones que puede incluir una función sortComparer, que mantendrá el array de IDs de elementos en orden clasificado comparando dos elementos (funciona igual que Array.sort()).
Devuelve un objeto que contiene un conjunto de funciones reductoras generadas para añadir, actualizar y eliminar elementos de un estado de entidad. Estas funciones pueden usarse como reductores de casos para tipos de acción específicos, o como funciones utilitarias "mutantes" dentro de otro reductor en createSlice.
El objeto adaptador también tiene una función getSelectors. Puedes pasarle un selector que devuelva este segmento concreto del estado desde el estado raíz de Redux, y generará selectores como selectAll y selectById.
Finalmente, el objeto adaptador tiene una función getInitialState que genera un objeto vacío {ids: [], entities: {}}. Puedes pasarle campos adicionales a getInitialState, y estos se fusionarán.
Normalización del segmento de posts
Con esto en mente, actualicemos nuestro postsSlice para usar createEntityAdapter. Necesitaremos hacer varios cambios.
Nuestra estructura PostsState cambiará. En lugar de tener posts: Post[] como array, ahora incluirá {ids: string[], entities: Record<string, Post>}. Redux Toolkit ya tiene un tipo EntityState que describe esa estructura {ids, entities}, así que lo importaremos como base para PostsState. También seguimos necesitando los campos status y error.
Necesitaremos importar createEntityAdapter, crear una instancia con el tipo Post aplicado correctamente, que sepa cómo ordenar los posts adecuadamente.
import {
createEntityAdapter,
EntityState
// omit other imports
} from '@reduxjs/toolkit'
// omit thunks
interface PostsState extends EntityState<Post, string> {
status: 'idle' | 'pending' | 'succeeded' | 'rejected'
error: string | null
}
const postsAdapter = createEntityAdapter<Post>({
// Sort in descending date order
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const initialState: PostsState = postsAdapter.getInitialState({
status: 'idle',
error: null
})
// omit thunks
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
// omit other cases
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Save the fetched posts into state
postsAdapter.setAll(state, action.payload)
})
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors((state: RootState) => state.posts)
export const selectPostsByUser = createSelector(
[selectAllPosts, (state: RootState, userId: string) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)
¡Hay mucho que analizar aquí! Vamos a desglosarlo.
Primero, importamos createEntityAdapter y lo llamamos para crear nuestro objeto postsAdapter. Queremos mantener un array de IDs de posts ordenados con los más recientes primero, así que pasamos una función sortComparer que ordenará los elementos más nuevos al principio basándose en post.date.
getInitialState() devuelve un objeto de estado normalizado vacío {ids: [], entities: {}}. Nuestro postsSlice necesita mantener los campos status y error para el estado de carga, así que se los pasamos a getInitialState().
Ahora que nuestros posts se almacenan como tabla de búsqueda en state.entities, podemos modificar los reductores reactionAdded y postUpdated para buscar directamente los posts por ID mediante state.entities[postId], sin recorrer el antiguo array posts.
Al recibir la acción fetchPosts.fulfilled, usamos postsAdapter.setAll para añadir todos los posts entrantes al estado, pasando el borrador state y el array de posts en action.payload. Esto es un ejemplo de usar métodos del adaptador como funciones utilitarias "mutantes" dentro de un reductor de createSlice.
Al recibir addNewPost.fulfilled, necesitamos añadir ese nuevo post. Podemos usar las funciones del adaptador como reductores directamente, así que asignamos postsAdapter.addOne como función reductora para esa acción. Aquí usamos el método del adaptador como el reductor real.
Finalmente, podemos reemplazar las funciones selectoras escritas manualmente selectAllPosts y selectPostById con las generadas por postsAdapter.getSelectors. Como los selectores se llaman con el objeto de estado raíz de Redux, necesitan saber dónde encontrar nuestros datos de publicaciones en el estado de Redux, así que pasamos un pequeño selector que devuelve state.posts. Las funciones selectoras generadas siempre se llaman selectAll y selectById, por lo que podemos usar sintaxis de desestructuración para renombrarlas al exportarlas y que coincidan con los nombres antiguos de los selectores. También exportaremos selectPostIds de la misma manera, ya que queremos leer la lista ordenada de IDs de publicaciones en nuestro componente <PostsList>.
Incluso podríamos eliminar un par de líneas más cambiando postUpdated para usar el método postsAdapter.updateOne. Este toma un objeto que luce como {id, changes}, donde changes es un objeto con campos a sobrescribir:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
postsAdapter.updateOne(state, { id, changes: { title, content } })
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
// omit `extraReducers`
})
Ten en cuenta que no podemos usar postsAdapter.updateOne con el reductor reactionAdded, porque es un poco más complejo. En lugar de simplemente reemplazar un campo en el objeto de publicación, necesitamos incrementar un contador anidado dentro de uno de los campos. En ese caso, está bien buscar el objeto y hacer una actualización "mutante" como hemos estado haciendo.
Optimizando la lista de publicaciones
Ahora que nuestra cortina de publicaciones usa createEntityAdapter, podemos actualizar <PostsList> para optimizar su comportamiento de renderizado.
Actualizaremos <PostsList> para que lea solo el array ordenado de IDs de publicaciones, y pase postId a cada <PostExcerpt>:
// omit other imports
import {
fetchPosts,
selectPostById,
selectPostIds,
selectPostsStatus,
selectPostsError
} from './postsSlice'
interface PostExcerptProps {
postId: string
}
function PostExcerpt({ postId }: PostExcerptProps) {
const post = useAppSelector(state => selectPostById(state, postId))
// omit rendering logic
}
export const PostsList = () => {
const dispatch = useAppDispatch()
const orderedPostIds = useAppSelector(selectPostIds)
// omit other selections and effects
if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}
// omit other rendering
}
Ahora, si intentamos hacer clic en un botón de reacción en una de las publicaciones mientras capturamos un perfil de rendimiento de componentes de React, deberíamos ver que solo ese componente se vuelve a renderizar:

Normalizando la cortina de usuarios
También podemos convertir otras cortinas para que usen createEntityAdapter.
La usersSlice es bastante pequeña, así que solo tenemos que cambiar algunas cosas:
import {
createSlice,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '@/api/client'
import { createAppAsyncThunk } from '@/app/withTypes'
const usersAdapter = createEntityAdapter<User>()
const initialState = usersAdapter.getInitialState()
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})
export default usersSlice.reducer
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors((state: RootState) => state.users)
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (!currentUsername) {
return
}
return selectUserById(state, currentUsername)
}
La única acción que manejamos aquí siempre reemplaza la lista completa de usuarios con el array que obtenemos del servidor. Podemos usar usersAdapter.setAll para implementar eso en su lugar.
Ya estábamos exportando los selectores selectAllUsers y selectUserById que escribimos manualmente. Podemos reemplazarlos con las versiones generadas por usersAdapter.getSelectors().
Ahora tenemos una ligera discrepancia de tipos con selectUserById: según los tipos, currentUsername puede ser null, pero el selector generado selectUserById no acepta ese valor. Una solución sencilla es verificar si existe y simplemente retornar antes si no es así.
Normalizando la cortina de notificaciones
Por último, pero no menos importante, también actualizaremos notificationsSlice:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
import { client } from '@/api/client'
// omit types and fetchNotifications thunk
const notificationsAdapter = createEntityAdapter<ClientNotification>({
// Sort with newest first
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const initialState = notificationsAdapter.getInitialState()
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))
Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
notificationsAdapter.upsertMany(state, notificationsWithMetadata)
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors((state: RootState) => state.notifications)
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Nuevamente importamos createEntityAdapter, lo llamamos y llamamos a notificationsAdapter.getInitialState() para ayudar a configurar la cortina.
Irónicamente, tenemos un par de lugares aquí donde necesitamos recorrer todos los objetos de notificación y actualizarlos. Como ya no se mantienen en un array, tenemos que usar Object.values(state.entities) para obtener un array de esas notificaciones y recorrerlo. Por otro lado, podemos reemplazar la lógica de actualización de fetch anterior con notificationsAdapter.upsertMany.
Escribiendo lógica reactiva
Hasta ahora, todo el comportamiento de nuestra aplicación ha sido relativamente imperativo. El usuario hace algo (añadir una publicación, obtener notificaciones) y despachamos acciones en un manejador de clics o en un hook useEffect de un componente como respuesta. Eso incluye los thunks de obtención de datos como fetchPosts y login.
Sin embargo, a veces necesitamos escribir más lógica que se ejecute en respuesta a cosas que suceden en la aplicación, como ciertas acciones que se despachan.
Ya hemos mostrado indicadores de carga para acciones como obtener publicaciones. Sería bueno tener una confirmación visual para el usuario cuando añade una nueva publicación, como mostrar un mensaje emergente tipo "toast".
Ya hemos visto que podemos tener múltiples reductores respondiendo a la misma acción. Esto funciona bien para lógica que simplemente "actualiza más partes del estado", pero ¿qué pasa si necesitamos escribir lógica asíncrona o con efectos secundarios? No podemos poner eso en los reductores - los reductores deben ser "puros" y no tener efectos secundarios.
Si no podemos poner esta lógica con efectos secundarios en los reductores, ¿dónde podemos ponerla?
La respuesta está dentro del middleware de Redux, porque el middleware está diseñado para permitir efectos secundarios.
Lógica Reactiva con createListenerMiddleware
Ya hemos usado el middleware thunk para lógica asíncrona que debe ejecutarse "inmediatamente". Sin embargo, los thunks son simplemente funciones. Necesitamos un tipo diferente de middleware que nos permita decir "cuando se despache una acción específica, ejecuta esta lógica adicional como respuesta".
Redux Toolkit incluye la API createListenerMiddleware que nos permite escribir lógica que se ejecuta como respuesta a acciones específicas despachadas. Nos permite añadir entradas "listener" que definen qué acciones buscar y tienen un callback effect que se ejecutará cuando coincida con una acción.
Conceptualmente, puedes pensar en createListenerMiddleware como similar al hook useEffect de React, excepto que se definen como parte de tu lógica de Redux en lugar de dentro de un componente React, y se ejecutan como respuesta a acciones despachadas y actualizaciones del estado de Redux en lugar de como parte del ciclo de vida de renderizado de React.
Configuración del Middleware Listener
No tuvimos que configurar específicamente el middleware thunk porque configureStore de Redux Toolkit lo añade automáticamente. Para el middleware listener, necesitaremos hacer un poco de trabajo de configuración para crearlo y añadirlo al store.
Crearemos un nuevo archivo app/listenerMiddleware.ts e instanciaremos allí el middleware listener. Similar a createAsyncThunk, pasaremos los tipos correctos para dispatch y state para poder acceder de forma segura a campos del estado y despachar acciones.
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const listenerMiddleware = createListenerMiddleware()
export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening
export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener
Al igual que createSlice, createListenerMiddleware devuelve un objeto que contiene varios campos:
-
listenerMiddleware.middleware: la instancia real del middleware de Redux que debe añadirse al store -
listenerMiddleware.startListening: añade directamente una nueva entrada listener al middleware -
listenerMiddleware.addListener: un creador de acciones que puede despacharse para añadir una entrada listener desde cualquier parte del código que tenga acceso adispatch, incluso sin importar el objetolistenerMiddleware
Como con los thunks asíncronos y hooks, podemos usar los métodos .withTypes() para definir funciones pre-tipadas startAppListening y addAppListener con los tipos correctos incorporados.
Luego, necesitamos añadirlo al store:
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
import notificationsReducer from '@/features/notifications/notificationsSlice'
import { listenerMiddleware } from './listenerMiddleware'
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})
configureStore ya añade el middleware redux-thunk por defecto, junto con middleware adicional en desarrollo que añade verificaciones de seguridad. Queremos preservar esos, pero también añadir el middleware listener.
El orden puede importar al configurar middleware, porque forman una tubería: m1 -> m2 -> m3 -> store.dispatch(). En este caso, el middleware listener debe estar al principio de la tubería para poder interceptar algunas acciones primero y procesarlas.
getDefaultMiddleware() devuelve un array del middleware configurado. Al ser un array, ya tiene un método .concat() que devuelve una copia con los nuevos elementos al final del array, pero configureStore también añade un método equivalente .prepend() que crea una copia con los nuevos elementos al principio del array.
Así que llamaremos a getDefaultMiddleware().prepend(listenerMiddleware.middleware) para añadirlo al inicio de la lista.
Mostrar Notificaciones para Nuevos Posts
Ahora que tenemos configurado el middleware de listeners, podemos añadir una nueva entrada de listener que mostrará una notificación cada vez que un nuevo post se añada correctamente.
Vamos a usar la biblioteca react-tiny-toast para gestionar la visualización de notificaciones con la apariencia adecuada. Ya está incluida en el repositorio del proyecto, así que no necesitamos instalarla.
Necesitamos importar y renderizar su componente <ToastContainer> en nuestra aplicación <App>:
import React from 'react'
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { ToastContainer } from 'react-tiny-toast'
// omit other imports and ProtectedRoute definition
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>{/* omit routes content */}</Routes>
<ToastContainer />
</div>
</Router>
)
}
Ahora podemos añadir un listener que vigilará la acción addNewPost.fulfilled, mostrará una notificación que diga "Post añadido" y la eliminará después de un retraso.
Existen múltiples enfoques para definir listeners en nuestro código. Dicho esto, suele ser una buena práctica definir los listeners en el archivo de slice que parezca más relacionado con la lógica que queremos añadir. En este caso, queremos mostrar una notificación cuando se añade un post, así que añadamos este listener en el archivo postsSlice:
import {
createEntityAdapter,
createSelector,
createSlice,
EntityState,
PayloadAction
} from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { AppStartListening } from '@/app/listenerMiddleware'
import { createAppAsyncThunk } from '@/app/withTypes'
// omit types, initial state, slice definition, and selectors
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: addNewPost.fulfilled,
effect: async (action, listenerApi) => {
const { toast } = await import('react-tiny-toast')
const toastId = toast.show('New post added!', {
variant: 'success',
position: 'bottom-right',
pause: true
})
await listenerApi.delay(5000)
toast.remove(toastId)
}
})
}
Para añadir un listener, necesitamos llamar a la función startAppListening definida en app/listenerMiddleware.ts. Sin embargo, es mejor si no importamos startAppListening directamente en el archivo del slice, para mantener las cadenas de importación más consistentes. En su lugar, podemos exportar una función que acepte startAppListening como argumento. De esta manera, el archivo app/listenerMiddleware.ts puede importar esta función, similar a como app/store.ts importa los reducers de slice de cada archivo de slice.
Para añadir una entrada de listener, llama a startAppListening y pasa un objeto con una función callback effect, y una de estas opciones para definir cuándo se ejecutará el callback de efecto:
-
actionCreator: ActionCreator: cualquier función creadora de acciones de RTK, comoreactionAddedoaddNewPost.fulfilled. Esto ejecutará el efecto cuando se despache esa acción específica. -
matcher: (action: UnknownAction) => boolean: cualquier función "matcher" de RTK, comoisAnyOf(reactionAdded, addNewPost.fulfilled). Esto ejecutará el efecto cada vez que el matcher devuelvatrue. -
predicate: (action: UnknownAction, currState: RootState, prevState: RootState) => boolean: una función de coincidencia más general que tiene acceso acurrStateyprevState. Puede usarse para hacer cualquier comprobación contra los valores de acción o estado, incluyendo ver si una parte del estado ha cambiado (comocurrState.counter.value !== prevState.counter.value)
En este caso, queremos específicamente mostrar nuestra notificación cada vez que el thunk addNewPost tenga éxito, así que especificaremos que el efecto debe ejecutarse con actionCreator: addNewPost.fulfilled.
El callback effect en sí es muy similar a un thunk asíncrono. Recibe la action coincidente como primer argumento y un objeto listenerApi como segundo argumento.
El listenerApi incluye los métodos habituales dispatch y getState, pero también varias otras funciones que pueden usarse para implementar lógica asíncrona compleja y flujos de trabajo. Esto incluye métodos como condition() para pausar hasta que se despache otra acción o cambie un valor de estado, unsubscribe()/subscribe() para cambiar si esta entrada de listener está activa, fork() para lanzar una tarea secundaria, y más.
En este caso, queremos importar dinámicamente la librería react-tiny-toast, mostrar la notificación de éxito, esperar unos segundos y luego eliminarla.
Finalmente, necesitamos importar y llamar a addPostsListeners en algún lugar. En este caso, lo importaremos en app/listenerMiddleware.ts:
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
import { addPostsListeners } from '@/features/posts/postsSlice'
export const listenerMiddleware = createListenerMiddleware()
export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening
export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener
// Call this and pass in `startAppListening` to let the
// posts slice set up its listeners
addPostsListeners(startAppListening)
Ahora, cuando añadamos una nueva publicación, deberíamos ver una pequeña notificación verde aparecer en la esquina inferior derecha de la página, que desaparecerá después de 5 segundos. Esto funciona porque el middleware de listeners en el store de Redux verifica y ejecuta el callback del efecto después de que se despacha la acción, a pesar de no haber añadido lógica adicional explícitamente en los componentes de React.
Lo que has aprendido
Hemos construido mucha funcionalidad nueva en esta sección. Veamos cómo queda la aplicación con todos estos cambios:
Esto es lo que hemos cubierto en esta sección:
- Las funciones selectoras memorizadas pueden usarse para optimizar el rendimiento
- Redux Toolkit reexporta la función
createSelectorde Reselect, que genera selectores memorizados - Los selectores memorizados solo recalculan los resultados si los selectores de entrada devuelven nuevos valores
- La memorización puede evitar cálculos costosos y garantizar que se devuelvan las mismas referencias de resultado
- Redux Toolkit reexporta la función
- Existen múltiples patrones para optimizar el renderizado de componentes React con Redux
- Evita crear nuevas referencias de objetos/arrays dentro de
useSelector: causarían rerenders innecesarios - Las funciones selectoras memorizadas pueden pasarse a
useSelectorpara optimizar el renderizado useSelectorpuede aceptar funciones de comparación alternativas comoshallowEqualen lugar de igualdad referencial- Los componentes pueden envolverse en
React.memo()para rerenderizar solo si cambian sus props - El renderizado de listas puede optimizarse haciendo que los componentes padres lean solo arrays de IDs, pasando estos IDs a los hijos, y recuperando los elementos por ID en los componentes hijos
- Evita crear nuevas referencias de objetos/arrays dentro de
- La estructura de estado normalizada es el enfoque recomendado para almacenar elementos
- "Normalización" significa evitar duplicación de datos, manteniendo elementos en una tabla de búsqueda por ID
- El estado normalizado suele tener la forma
{ids: [], entities: {}}
- La API
createEntityAdapterde Redux Toolkit ayuda a gestionar datos normalizados en un slice- Los IDs pueden mantenerse ordenados pasando una opción
sortComparer - El objeto adaptador incluye:
adapter.getInitialState, que acepta campos de estado adicionales como estado de carga- Reductores preconstruidos para casos comunes como
setAll,addMany,upsertOneyremoveMany adapter.getSelectors, que genera selectores comoselectAllyselectById
- Los IDs pueden mantenerse ordenados pasando una opción
- La API
createListenerMiddlewarede Redux Toolkit se usa para ejecutar lógica reactiva en respuesta a acciones despachadas- El middleware de listeners debe añadirse en la configuración del store, con los tipos de store adecuados
- Los listeners suelen definirse en slice files, pero pueden estructurarse de otras formas
- Los listeners pueden coincidir con acciones individuales, múltiples acciones o usar comparaciones personalizadas
- Los callbacks de efecto del listener pueden contener lógica síncrona o asíncrona
- El objeto
listenerApiproporciona métodos para gestionar flujos asíncronos y comportamientos
¿Qué sigue?
Redux Toolkit también incluye una potente API para fetching de datos y caché llamada "RTK Query". RTK Query es un addon opcional que puede eliminar completamente la necesidad de escribir lógica de fetching de datos. En Parte 7: Fundamentos de RTK Query, aprenderás qué es RTK Query, qué problemas resuelve y cómo usarlo para obtener y usar datos cacheados en tu aplicación.