Redux Essentials, Parte 4: Usando Datos de Redux
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
- Usar datos de Redux en múltiples componentes React
- Organizar lógica que despacha acciones
- Usar selectores para buscar valores de estado
- Escribir lógica de actualización más compleja en reductores
- Cómo conceptualizar las acciones de Redux
- Comprensión del flujo de datos de Redux y las APIs de React-Redux de la Parte 3
- Familiaridad con los componentes
<Link>y<Route>de React Router para enrutamiento
Introducción
En Parte 3: Flujo Básico de Datos en Redux, vimos cómo comenzar desde una configuración vacía de proyecto Redux+React, añadir un nuevo slice de estado, y crear componentes React que pueden leer datos del almacén de Redux y despachar acciones para actualizar esos datos. También examinamos cómo fluyen los datos a través de la aplicación: los componentes despachan acciones, los reductores procesan esas acciones y devuelven nuevo estado, y los componentes leen el nuevo estado y vuelven a renderizar la UI. Además, vimos cómo crear versiones "pre-tipadas" de los hooks useSelector y useDispatch que aplican automáticamente los tipos correctos del almacén.
Ahora que conoces los pasos fundamentales para escribir lógica de Redux, vamos a usar esos mismos pasos para añadir nuevas funcionalidades a nuestro feed de redes sociales que lo harán más útil: ver publicaciones individuales, editar publicaciones existentes, mostrar detalles del autor, marcas de tiempo, botones de reacciones y autenticación.
Como recordatorio, los ejemplos de código se centran en los conceptos clave y cambios de cada sección. Consulta los proyectos de CodeSandbox y la rama tutorial-steps-ts en el repositorio del proyecto para ver los cambios completos en la aplicación.
Mostrar Publicaciones Individuales
Como tenemos la capacidad de añadir nuevas publicaciones al almacén de Redux, podemos agregar más funcionalidades que utilicen estos datos de diferentes maneras.
Actualmente, nuestras publicaciones se muestran en la página principal del feed, pero si el texto es demasiado largo, solo mostramos un extracto del contenido. Sería útil poder ver una publicación individual en su propia página.
Creando una Página de Publicación Individual
Primero, necesitamos añadir un nuevo componente SinglePostPage a nuestra carpeta de características de posts. Usaremos React Router para mostrar este componente cuando la URL sea como /posts/123, donde 123 debe ser el ID de la publicación que queremos mostrar.
import { useParams } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
)
}
Al configurar la ruta para renderizar este componente, indicaremos que interprete la segunda parte de la URL como una variable llamada postId, valor que podemos leer mediante el hook useParams.
Una vez que tenemos ese valor de postId, podemos usarlo dentro de una función selectora para encontrar el objeto de publicación correcto en el almacén de Redux. Sabemos que state.posts debe ser un array de todos los objetos de publicación, por lo que podemos usar la función Array.find() para iterar en el array y devolver la publicación con el ID que buscamos.
Es importante destacar que el componente se volverá a renderizar cada vez que el valor devuelto por useAppSelector cambie a una nueva referencia. Los componentes siempre deben intentar seleccionar la mínima cantidad posible de datos que necesitan del almacén, lo que ayudará a garantizar que solo se rendericen cuando realmente sea necesario.
Es posible que no tengamos una publicación coincidente en el almacén - quizás el usuario intentó escribir la URL directamente o no tenemos los datos correctos cargados. Si eso ocurre, la función find() devolverá undefined en lugar de un objeto de publicación real. Nuestro componente debe verificar esto y manejarlo mostrando un mensaje "¡Publicación no encontrada!" en la página.
Asumiendo que tenemos el objeto de publicación correcto en el almacén, useAppSelector lo devolverá, y podemos utilizarlo para renderizar el título y el contenido de la publicación en la página.
Tal vez notes que esto se parece bastante a la lógica que tenemos en el cuerpo de nuestro componente <PostsList>, donde recorremos todo el array de posts para mostrar extractos en el feed principal. Podríamos intentar extraer un componente Post que pudiera usarse en ambos lugares, pero ya hay diferencias en cómo mostramos un extracto de publicación versus la publicación completa. Normalmente es mejor mantener una implementación separada durante un tiempo incluso si hay cierta duplicación, y luego decidir más tarde si las secciones de código son lo suficientemente similares como para extraer un componente reutilizable.
Añadir la ruta de publicación única
Ahora que tenemos un componente <SinglePostPage>, podemos definir una ruta para mostrarlo y añadir enlaces a cada publicación en el feed de la página principal.
De paso, también vale la pena extraer el contenido de la "página principal" en un componente separado <PostsMainPage> para mejorar la legibilidad.
Importaremos PostsMainPage y SinglePostPage en App.tsx, y añadiremos la ruta:
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
</Routes>
</div>
</Router>
)
}
export default App
Luego, en <PostsList>, actualizaremos la lógica de renderizado de la lista para incluir un <Link> que enrute a esa publicación específica:
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
export const PostsList = () => {
const posts = useAppSelector(state => state.posts)
const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
</article>
))
return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}
Y dado que ahora podemos hacer clic para ir a una página diferente, también sería útil tener un enlace de vuelta a la página principal de publicaciones en el componente <Navbar>:
import { Link } from 'react-router-dom'
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
</div>
</div>
</section>
</nav>
)
}
Editar publicaciones
Como usuario, es muy frustrante terminar de escribir una publicación, guardarla y darse cuenta de que hubo un error. Tener la capacidad de editar una publicación después de crearla sería muy útil.
Añadamos un nuevo componente <EditPostForm> que pueda tomar un ID de publicación existente, leer esa publicación del almacén, permitir al usuario editar el título y el contenido, y luego guardar los cambios para actualizar la publicación en el almacén.
Actualizar entradas de publicaciones
Primero necesitamos actualizar nuestro postsSlice para crear una nueva función reductora y una acción que permita al almacén saber cómo actualizar publicaciones.
Dentro de la llamada createSlice(), debemos añadir una nueva función en el objeto reducers. Recuerda que el nombre de este reductor debe describir bien lo que sucede, porque veremos este nombre como parte del tipo de acción en Redux DevTools cuando se despache esta acción. Nuestro primer reductor se llamó postAdded, así que llamemos a este postUpdated.
Redux en sí no se preocupa por el nombre que uses para estas funciones reductoras - funcionará igual si se llama postAdded, addPost, POST_ADDED o someRandomName.
Dicho esto, recomendamos nombrar reductores en tiempo pasado como "esto ocurrió": postAdded, porque describimos "un evento que sucedió en la aplicación".
Para actualizar un objeto de publicación, necesitamos saber:
-
El ID de la publicación que se actualiza, para poder encontrar el objeto correcto en el estado
-
Los nuevos campos
titleycontentque el usuario haya escrito
Los objetos de acción de Redux deben tener un campo type, que normalmente es una cadena descriptiva, y pueden contener otros campos con más información sobre lo sucedido. Por convención, normalmente ponemos la información adicional en un campo llamado action.payload, pero depende de nosotros decidir qué contiene el campo payload - puede ser una cadena, número, objeto, array u otra cosa. En este caso, como tenemos tres piezas de información, planeemos que el campo payload sea un objeto con estos tres campos dentro. Esto significa que el objeto de acción se verá como {type: 'posts/postUpdated', payload: {id, title, content}}.
Por defecto, los creadores de acciones generados por createSlice esperan que pases un argumento, y ese valor se colocará en el objeto de acción como action.payload. Por lo tanto, podemos pasar un objeto que contenga esos campos como argumento al creador de acciones postUpdated. Al igual que con postAdded, esto es un objeto Post completo, por lo que declaramos que el argumento del reducer es action: PayloadAction<Post>.
También sabemos que el reducer es responsable de determinar cómo se debe actualizar el estado cuando se despacha una acción. Por lo tanto, debemos hacer que el reducer encuentre el objeto de publicación correcto basándose en el ID, y que actualice específicamente los campos title y content de esa publicación.
Por último, necesitaremos exportar la función creadora de acciones que createSlice generó para nosotros, para que la interfaz de usuario pueda despachar la nueva acción postUpdated cuando el usuario guarde la publicación.
Dados todos estos requisitos, así es como debería quedar la definición de nuestro postsSlice cuando hayamos terminado:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// omit state types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
postUpdated(state, action: PayloadAction<Post>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
export const { postAdded, postUpdated } = postsSlice.actions
export default postsSlice.reducer
Creación de un formulario de edición de publicaciones
Nuestro nuevo componente <EditPostForm> se parecerá tanto a <AddPostForm> como a <SinglePostPage>, pero la lógica debe ser un poco diferente. Necesitamos recuperar el objeto post correcto del almacén basándonos en el postId de la URL, y luego usarlo para inicializar los campos de entrada en el componente para que el usuario pueda realizar cambios. Guardaremos los valores modificados del título y el contenido de vuelta en el almacén cuando el usuario envíe el formulario. También usaremos el hook useNavigate de React Router para cambiar a la página de publicación única y mostrar esa publicación después de guardar los cambios.
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { postUpdated } from './postsSlice'
// omit form element types
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)
const dispatch = useAppDispatch()
const navigate = useNavigate()
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const onSavePostClicked = (e: React.FormEvent<EditPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
if (title && content) {
dispatch(postUpdated({ id: post.id, title, content }))
navigate(`/posts/${postId}`)
}
}
return (
<section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue={post.title}
required
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue={post.content}
required
/>
<button>Save Post</button>
</form>
</section>
)
}
Ten en cuenta que el código específico de Redux aquí es relativamente mínimo. Una vez más, leemos un valor del almacén de Redux mediante useAppSelector, y luego despachamos una acción mediante useAppDispatch cuando el usuario interactúa con la interfaz de usuario.
Al igual que con SinglePostPage, necesitaremos importarlo en App.tsx y añadir una ruta que renderice este componente con el postId como parámetro de ruta.
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</div>
</Router>
)
}
export default App
También deberíamos añadir un nuevo enlace a nuestra SinglePostPage que enrute a EditPostForm, como:
import { Link, useParams } from 'react-router-dom'
export const SinglePostPage = () => {
// omit other contents
<p className="post-content">{post.content}</p>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
Preparación de Payloads de Acción
Acabamos de ver que los creadores de acciones de createSlice normalmente esperan un argumento, que se convierte en action.payload. Esto simplifica el patrón de uso más común, pero a veces necesitamos hacer más trabajo para preparar el contenido de un objeto de acción. En el caso de nuestra acción postAdded, necesitamos generar un ID único para la nueva publicación y también debemos asegurarnos de que el payload sea un objeto que se parezca a {id, title, content}.
Actualmente, estamos generando el ID y creando el objeto payload en nuestro componente React, y pasando este objeto a postAdded. Pero, ¿qué pasaría si necesitáramos despachar la misma acción desde diferentes componentes, o si la lógica para preparar el payload fuera compleja? Tendríamos que duplicar esa lógica cada vez que quisiéramos despachar la acción, y forzaríamos al componente a conocer exactamente cómo debe estructurarse el payload para esta acción.
Si una acción necesita contener un ID único u otro valor aleatorio, siempre genera ese valor primero y colócalo en el objeto de la acción. Los reductores nunca deben calcular valores aleatorios, porque eso hace que los resultados sean impredecibles.
Si estuviéramos escribiendo el creador de acciones postAdded manualmente, podríamos haber puesto la lógica de configuración dentro de él nosotros mismos:
// hand-written action creator
function postAdded(title: string, content: string) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
Sin embargo, createSlice de Redux Toolkit está generando estos creadores de acciones por nosotros. Eso hace que el código sea más corto porque no tenemos que escribirlos nosotros mismos, pero todavía necesitamos una manera de personalizar el contenido de action.payload.
Afortunadamente, createSlice nos permite definir una función "prepare callback" al escribir un reducer. Esta función "prepare callback" puede recibir múltiples argumentos, generar valores aleatorios como IDs únicos y ejecutar cualquier lógica síncrona necesaria para determinar qué valores van en el objeto de acción. Luego debe devolver un objeto con el campo payload. (El objeto devuelto también puede contener un campo meta para añadir valores descriptivos extra a la acción, y un campo error que indique con un booleano si esta acción representa algún tipo de error.)
Dentro del campo reducers en createSlice, podemos definir uno de los campos como un objeto con la estructura {reducer, prepare}:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string) {
return {
payload: { id: nanoid(), title, content }
}
}
}
// other reducers here
}
})
Ahora nuestro componente no tiene que preocuparse por cómo es el objeto payload: el creador de acciones se encargará de estructurarlo correctamente. Así que podemos actualizar el componente para que pase title y content como argumentos cuando despacha postAdded:
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Now we can pass these in as separate arguments,
// and the ID will be generated automatically
dispatch(postAdded(title, content))
e.currentTarget.reset()
}
Lectura de datos con selectores
Ahora tenemos varios componentes que buscan un post por ID, repitiendo la llamada state.posts.find(). Esto es código duplicado y siempre vale la pena considerar si debemos eliminar duplicados. También es frágil: como veremos más adelante, eventualmente cambiaremos la estructura del estado en el slice de posts. Cuando lo hagamos, tendremos que buscar cada lugar donde referenciemos state.posts y actualizar la lógica. TypeScript ayudará detectando código roto que no coincida con el tipo de estado esperado mediante errores en tiempo de compilación, pero sería ideal no tener que reescribir componentes cada vez que cambiemos el formato de datos en nuestros reducers, y no repetir lógica en los componentes.
Una forma de evitarlo es definir funciones selectoras reutilizables en los archivos de slice, y que los componentes usen esos selectores para extraer los datos necesarios en lugar de repetir la lógica de selección en cada componente. Así, si cambiamos la estructura del estado nuevamente, solo necesitamos actualizar el código en el archivo del slice.
Definición de funciones selectoras
Ya has escrito funciones selectoras cada vez que llamaste a useAppSelector, como useAppSelector( state => state.posts ). En ese caso, el selector se define inline. Como es solo una función, también podríamos escribirlo como:
const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)
Los selectores típicamente se escriben como funciones individuales independientes en el archivo del slice. Normalmente aceptan todo el estado RootState de Redux como primer argumento, y también pueden aceptar otros argumentos.
Extracción de selectores de posts
El componente <PostsList> necesita leer la lista completa de posts, mientras que <SinglePostPage> y <EditPostForm> necesitan buscar un post individual por su ID. Exportemos dos funciones selectoras pequeñas desde postsSlice.ts para cubrir estos casos:
import type { RootState } from '@/app/store'
const postsSlice = createSlice(/* omit slice code*/)
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
export const selectAllPosts = (state: RootState) => state.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.find(post => post.id === postId)
Nota que el parámetro state para estas funciones selectoras es el objeto de estado raíz de Redux, igual que en los selectores anónimos inline que escribimos directamente dentro de useAppSelector.
Podemos usarlas luego en los componentes:
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
// omit imports
import { postUpdated, selectPostById } from './postsSlice'
export const EditPostForm = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
Ten en cuenta que el postId obtenido de useParams() tiene tipo string | undefined, pero selectPostById espera un string válido como argumento. Podemos usar el operador ! de TS para indicar al compilador que este valor no será undefined en este punto del código. (Esto puede ser peligroso, pero podemos asumirlo porque sabemos que el enrutamiento solo muestra <EditPostForm> si hay un ID de post en la URL.)
Continuaremos este patrón de escribir selectores en los slices a medida que avancemos, en lugar de escribirlos inline dentro de useAppSelector en los componentes. Recuerda, no es obligatorio, ¡pero es un buen patrón a seguir!
Uso efectivo de selectores
Suele ser buena idea encapsular las búsquedas de datos mediante selectores reutilizables. Idealmente, los componentes ni siquiera deberían saber dónde reside un valor en el state de Redux: simplemente usan un selector del slice para acceder a los datos.
También puedes crear selectores "memorizados" que ayudan a mejorar el rendimiento optimizando los rerenders y evitando recálculos innecesarios, lo cual veremos en una parte posterior de este tutorial.
Pero, como cualquier abstracción, no es algo que debas hacer siempre y en todas partes. Escribir selectores significa más código que entender y mantener. No sientas que necesitas escribir selectores para cada campo de tu estado. Intenta comenzar sin selectores y añádelos más tarde cuando veas que consultas los mismos valores en muchas partes de tu aplicación.
Opcional: Definir selectores dentro de createSlice
Hemos visto que podemos escribir selectores como funciones independientes en los archivos de slice. En algunos casos, puedes acortar esto definiendo selectores directamente dentro del propio createSlice.
Defining Selectors inside createSlice
We've already seen that createSlice requires the name, initialState, and reducers fields, and also accepts an optional extraReducers field.
If you want to define selectors directly inside of createSlice, you can pass in an additional selectors field. The selectors field should be an object similar to reducers, where the keys will be the selector function names, and the values are the selector functions to be generated.
Note that unlike writing a standalone selector function, the state argument to these selectors will be just the slice state, and not the entire RootState!.
Here's what it might look like to convert the posts slice selectors to be defined inside of createSlice:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
/* omit reducer logic */
},
selectors: {
// Note that these selectors are given just the `PostsState`
// as an argument, not the entire `RootState`
selectAllPosts: postsState => postsState,
selectPostById: (postsState, postId: string) => {
return postsState.find(post => post.id === postId)
}
}
})
export const { selectAllPosts, selectPostById } = postsSlice.selectors
export default postsSlice.reducer
// We've replaced these standalone selectors:
// export const selectAllPosts = (state: RootState) => state.posts
// export const selectPostById = (state: RootState, postId: string) =>
// state.posts.find(post => post.id === postId)
There are still times you'll need to write selectors as standalone functions outside of createSlice. This is especially true if you're calling other selectors that need the entire RootState as their argument, in order to make sure the types match up correctly.
Usuarios y publicaciones
Hasta ahora solo tenemos un slice de estado. La lógica está definida en postsSlice.ts, los datos se almacenan en state.posts y todos nuestros componentes han estado relacionados con la funcionalidad de publicaciones. Las aplicaciones reales probablemente tendrán muchos slices de estado diferentes y varias "carpetas de funcionalidad" para la lógica de Redux y los componentes de React.
¡No puedes tener una "app de redes sociales" si no hay más personas involucradas! Añadamos la capacidad de gestionar una lista de usuarios en nuestra app y actualicemos la funcionalidad relacionada con publicaciones para usar esos datos.
Añadiendo un slice de usuarios
Como el concepto de "usuarios" es diferente al de "publicaciones", queremos mantener separados el código y los datos de usuarios de los de publicaciones. Añadiremos una nueva carpeta features/users y colocaremos allí un archivo usersSlice. Al igual que con el slice de publicaciones, por ahora añadiremos algunas entradas iniciales para tener datos con los que trabajar.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '@/app/store'
interface User {
id: string
name: string
}
const initialState: User[] = [
{ id: '0', name: 'Tianna Jenkins' },
{ id: '1', name: 'Kevin Grant' },
{ id: '2', name: 'Madison Price' }
]
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {}
})
export default usersSlice.reducer
export const selectAllUsers = (state: RootState) => state.users
export const selectUserById = (state: RootState, userId: string | null) =>
state.users.find(user => user.id === userId)
Por ahora no necesitamos actualizar los datos realmente, así que dejaremos el campo reducers como un objeto vacío. (Volveremos a esto en una sección posterior).
Como antes, importaremos el usersReducer en nuestro archivo de store y lo añadiremos a la configuración:
import { configureStore } from '@reduxjs/toolkit'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})
Ahora, el estado raíz se verá como {posts, users}, coincidiendo con el objeto que pasamos como argumento reducer.
Añadiendo autores para las publicaciones
Cada publicación en nuestra app fue escrita por uno de nuestros usuarios, y cada vez que añadimos una nueva publicación, debemos registrar qué usuario la escribió. Esto requerirá cambios tanto en el estado de Redux como en el componente <AddPostForm>.
Primero, necesitamos actualizar el tipo de datos Post existente para incluir un campo user: string que contenga el ID del usuario que creó la publicación. También actualizaremos las entradas de publicaciones existentes en initialState para que tengan un campo post.user con uno de los IDs de usuario de ejemplo.
Luego, necesitamos actualizar nuestros reductores existentes en consecuencia. La devolución de llamada prepare de postAdded necesita aceptar un ID de usuario como argumento e incluirlo en la acción. Además, no queremos incluir el campo user cuando actualicemos una publicación; lo único que necesitamos son el id de la publicación que cambió, y los nuevos campos title y content para el texto actualizado. Definiremos un tipo PostUpdate que contenga solo esos tres campos de Post, y lo usaremos como payload para postUpdated en su lugar.
export interface Post {
id: string
title: string
content: string
user: string
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!', user: '0' },
{ id: '2', title: 'Second Post', content: 'More text', user: '2' }
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})
Ahora, en nuestro <AddPostForm>, podemos leer la lista de usuarios del store con useSelector y mostrarlos como un desplegable. Luego tomaremos el ID del usuario seleccionado y lo pasaremos al creador de acciones postAdded. De paso, podemos añadir un poco de lógica de validación a nuestro formulario para que el usuario solo pueda hacer clic en el botón "Guardar publicación" si los campos de título y contenido tienen texto real:
import { selectAllUsers } from '@/features/users/usersSlice'
// omit other imports and form types
const AddPostForm = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const userId = elements.postAuthor.value
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue=""
required
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" name="postAuthor" required>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}
Ahora necesitamos una forma de mostrar el nombre del autor de cada publicación dentro de nuestros elementos de lista y en <SinglePostPage>. Como queremos mostrar este mismo tipo de información en varios lugares, podemos crear un componente PostAuthor que reciba un ID de usuario como prop, busque el objeto de usuario correspondiente y formatee su nombre:
import { useAppSelector } from '@/app/hooks'
import { selectUserById } from '@/features/users/usersSlice'
interface PostAuthorProps {
userId: string
}
export const PostAuthor = ({ userId }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))
return <span>by {author?.name ?? 'Unknown author'}</span>
}
Observa que seguimos el mismo patrón en cada uno de nuestros componentes. Cualquier componente que necesite leer datos del almacén Redux puede usar el hook useAppSelector y extraer las piezas específicas de datos que requiere. Además, múltiples componentes pueden acceder simultáneamente a los mismos datos en el almacén Redux.
Ahora podemos importar el componente PostAuthor tanto en PostsList.tsx como en SinglePostPage.tsx, y renderizarlo como <PostAuthor userId={post.user} />. Cada vez que agreguemos una publicación, el nombre del usuario seleccionado aparecerá junto al contenido.
Más funcionalidades para publicaciones
En este punto, podemos crear y editar publicaciones. Añadamos lógica adicional para hacer nuestro feed más útil.
Almacenamiento de fechas para publicaciones
Los feeds de redes sociales suelen ordenarse por fecha de creación y muestran el tiempo transcurrido con descripciones relativas como "hace 5 horas". Para lograrlo, necesitamos empezar a rastrear un campo date en nuestras publicaciones.
Al igual que con el campo post.user, actualizaremos nuestra función prepare de postAdded para asegurarnos de que post.date siempre se incluya al despachar la acción. Sin embargo, no será un parámetro adicional que pasaremos. Queremos usar siempre la marca temporal exacta del momento en que se despacha la acción, por lo que delegaremos esto a la función prepare.
Las acciones y el estado de Redux deben contener solo valores JavaScript simples como objetos, arrays y primitivos. ¡No incluyas instancias de clases, funciones, instancias de Date/Map/Set u otros valores no serializables en Redux!
Como no podemos poner una instancia de clase Date en el almacén Redux, almacenaremos el valor post.date como una cadena de marca temporal. Lo añadiremos a los valores iniciales del estado (usando date-fns para restar algunos minutos de la fecha y hora actuales), y también lo agregaremos a cada nueva publicación en la función prepare:
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
const initialState: Post[] = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId
}
}
}
}
// omit `postUpdated
}
})
Al igual con los autores de las publicaciones, necesitamos mostrar la descripción temporal relativa tanto en <PostsList> como en <SinglePostPage>. Crearemos un componente <TimeAgo> para formatear cadenas de marca temporal como descripciones relativas. Bibliotecas como date-fns ofrecen funciones útiles para analizar y formatear fechas:
import { parseISO, formatDistanceToNow } from 'date-fns'
interface TimeAgoProps {
timestamp: string
}
export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<time dateTime={timestamp} title={timestamp}>
<i>{timeAgo}</i>
</time>
)
}
Ordenando la lista de publicaciones
Actualmente, <PostsList> muestra las publicaciones en el mismo orden en que se almacenan en Redux. Nuestro ejemplo muestra primero la publicación más antigua, y cada nueva publicación se añade al final del array. Esto significa que la publicación más reciente siempre aparece al final de la página.
Normalmente, los feeds de redes sociales muestran primero las publicaciones más recientes, y se desplazan hacia abajo para ver las más antiguas. Aunque los datos se almacenen en orden cronológico ascendente, podemos reordenarlos en <PostsList> para que la publicación más nueva aparezca primero. En teoría, como sabemos que el array state.posts ya está ordenado, podríamos invertir la lista. Pero es mejor ordenarlo explícitamente para asegurarnos.
Como array.sort() muta el array original, necesitamos copiar state.posts y ordenar la copia. Sabemos que nuestros campos post.date son cadenas de marca temporal y podemos compararlas directamente para ordenar las publicaciones correctamente:
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
const renderedPosts = orderedPosts.map(post => {
return (
// omit rendering logic
)
})
Botones de reacción para publicaciones
Actualmente, nuestras publicaciones son algo aburridas. Necesitamos hacerlas más interesantes, ¿y qué mejor manera que permitir que nuestros amigos añadan emojis de reacción? 🎉
Añadiremos una fila de botones de reacciones con emojis en la parte inferior de cada publicación en <PostsList> y <SinglePostPage>. Cada vez que un usuario haga clic en uno de estos botones, necesitaremos actualizar un contador correspondiente para esa publicación en el almacén de Redux. Como los contadores de reacciones están en Redux, al navegar por diferentes partes de la aplicación se mostrarán consistentemente los mismos valores en cualquier componente que use esos datos.
Seguimiento de las reacciones en las publicaciones
Actualmente no tenemos un campo post.reactions en nuestros datos, así que necesitamos actualizar los objetos de publicación en initialState y la función callback prepare de postAdded para asegurarnos de que cada publicación tenga estos datos: reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0}.
Luego, podemos definir un nuevo reductor que maneje la actualización del contador de reacciones cuando un usuario haga clic en un botón.
Al igual que al editar publicaciones, necesitamos conocer el ID de la publicación y qué botón de reacción pulsó el usuario. Nuestro action.payload será un objeto como {id, reaction}. El reductor podrá entonces encontrar la publicación correcta y actualizar el campo de reacciones correspondiente.
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
export interface Reactions {
thumbsUp: number
tada: number
heart: number
rocket: number
eyes: number
}
export type ReactionName = keyof Reactions
export interface Post {
id: string
title: string
content: string
user: string
date: string
reactions: Reactions
}
type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>
const initialReactions: Reactions = {
thumbsUp: 0,
tada: 0,
heart: 0,
rocket: 0,
eyes: 0
}
const initialState: Post[] = [
// omit initial state
]
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit other reducers
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
Como ya hemos visto, createSlice nos permite escribir lógica "mutadora" en nuestros reductores. Si no estuviéramos usando createSlice y la biblioteca Immer, la línea existingPost.reactions[reaction]++ mutaría el objeto post.reactions, lo que probablemente causaría errores por no seguir las reglas de inmutabilidad. Pero como sí usamos createSlice, podemos escribir esta lógica compleja de forma simple y dejar que Immer se encargue de convertir esto en una actualización inmutable segura.
Fíjate que nuestro objeto de acción solo contiene la mínima información necesaria para describir lo ocurrido. Sabemos qué publicación actualizar y qué reacción se ha pulsado. Podríamos haber calculado el nuevo valor del contador y ponerlo en la acción, pero es mejor mantener los objetos de acción lo más pequeños posible y hacer los cálculos de actualización en el reductor. Esto también permite que los reductores contengan toda la lógica necesaria para calcular el nuevo estado. De hecho, ¡la lógica de actualización del estado debe ir en el reductor!. Esto evita duplicar lógica en componentes diferentes o casos donde la capa de interfaz no tenga los datos más recientes.
Al usar Immer, puedes "mutar" un objeto de estado existente o devolver un nuevo valor de estado, pero no ambas cosas a la vez. Consulta la guía de Immer sobre Pitfalls y Returning New Data para más detalles.
Mostrar botones de reacciones
Como con autores y marcas de tiempo, queremos usar esto dondequiera que mostremos publicaciones, así que crearemos un componente <ReactionButtons> que reciba una post como prop. Cuando el usuario haga clic en un botón, despacharemos la acción reactionAdded con el nombre del emoji correspondiente.
import { useAppDispatch } from '@/app/hooks'
import type { Post, ReactionName } from './postsSlice'
import { reactionAdded } from './postsSlice'
const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
interface ReactionButtonsProps {
post: Post
}
export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const dispatch = useAppDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => dispatch(reactionAdded({ postId: post.id, reaction }))}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)
return <div>{reactionButtons}</div>
}
Ahora, cada vez que pulsemos un botón de reacción, su contador deberá incrementarse. Si navegamos por diferentes partes de la aplicación, veremos los valores correctos cada vez que veamos esta publicación, incluso si pulsamos un botón en <PostsList> y luego vemos la publicación individualmente en <SinglePostPage>. Esto ocurre porque cada componente lee los mismos datos del almacén de Redux.
Añadir inicio de sesión de usuario
Nos queda una última característica por añadir en esta sección.
Actualmente, simplemente seleccionamos qué usuario escribe cada publicación en <AddPostForm>. Para darle más realismo, convendría que el usuario iniciara sesión en la aplicación, así sabríamos quién escribe las publicaciones (y será útil para otras características más adelante).
Dado que se trata de una aplicación de ejemplo pequeña, no vamos a implementar comprobaciones de autenticación reales (y el objetivo aquí es aprender a usar las características de Redux, no cómo implementar realmente una autenticación). En su lugar, simplemente mostraremos una lista de nombres de usuario y permitiremos que el usuario real seleccione uno de ellos.
Para este ejemplo, simplemente añadiremos un slice auth que rastree state.auth.username para saber quién es el usuario. Así, podemos usar esa información cada vez que añada una publicación para agregar automáticamente el ID de usuario correcto.
Adición de un Slice de Autenticación
El primer paso es crear el authSlice y añadirlo al store. Es el mismo patrón que ya hemos visto: definir el estado inicial, escribir el slice con un par de reductores para manejar las actualizaciones de inicio y cierre de sesión, y agregar el reductor del slice al store.
En este caso, nuestro estado de autenticación es simplemente el nombre de usuario actualmente conectado, y lo restableceremos a null si cierra sesión.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
username: string | null
}
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,
reducers: {
userLoggedIn(state, action: PayloadAction<string>) {
state.username = action.payload
},
userLoggedOut(state) {
state.username = null
}
}
})
export const { userLoggedIn, userLoggedOut } = authSlice.actions
export const selectCurrentUsername = (state: RootState) => state.auth.username
export default authSlice.reducer
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer
}
})
Adición de la Página de Inicio de Sesión
Actualmente, la pantalla principal de la aplicación es el componente <Posts> con la lista de publicaciones y el formulario para añadir publicaciones. Vamos a cambiar ese comportamiento. En su lugar, queremos que el usuario primero vea una pantalla de inicio de sesión, y solo pueda ver la página de publicaciones después de haber iniciado sesión.
Primero, crearemos un componente <LoginPage>. Este leerá la lista de usuarios del store, los mostrará en un menú desplegable y despachará la acción userLoggedIn cuando se envíe el formulario. También navegaremos a la ruta /posts para poder ver la <PostsMainPage> después del inicio de sesión:
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'
import { userLoggedIn } from './authSlice'
interface LoginPageFormFields extends HTMLFormControlsCollection {
username: HTMLSelectElement
}
interface LoginPageFormElements extends HTMLFormElement {
readonly elements: LoginPageFormFields
}
export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()
const handleSubmit = (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()
const username = e.currentTarget.elements.username.value
dispatch(userLoggedIn(username))
navigate('/posts')
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Welcome to Tweeter!</h2>
<h3>Please log in:</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="username">User:</label>
<select id="username" name="username" required>
<option value=""></option>
{usersOptions}
</select>
<button>Log In</button>
</form>
</section>
)
}
A continuación, debemos actualizar el enrutamiento en el componente <App>. Debe mostrar <LoginPage> para la ruta raíz /, y también redirigir cualquier acceso no autorizado a otras páginas para que el usuario vuelva a la pantalla de inicio de sesión.
Una forma común de hacer esto es añadir un componente de "ruta protegida" que acepte algunos componentes de React como children, realice una comprobación de autorización y solo muestre los componentes hijos si el usuario está autorizado. Podemos añadir un componente <ProtectedRoute> que lea nuestro valor state.auth.username y lo use para la comprobación de autenticación, y luego envolver toda la sección relacionada con publicaciones en la configuración de enrutamiento con ese <ProtectedRoute>:
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { useAppSelector } from './app/hooks'
import { Navbar } from './components/Navbar'
import { LoginPage } from './features/auth/LoginPage'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'
import { selectCurrentUsername } from './features/auth/authSlice'
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const username = useAppSelector(selectCurrentUsername)
if (!username) {
return <Navigate to="/" replace />
}
return children
}
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
)
}
export default App
Ahora deberíamos ver funcionando ambos aspectos del comportamiento de autenticación:
-
Si el usuario intenta acceder a
/postssin haber iniciado sesión, el componente<ProtectedRoute>redirigirá a/y mostrará la<LoginPage> -
Cuando el usuario inicie sesión, despacharemos
userLoggedIn()para actualizar el estado de Redux y luego forzaremos la navegación a/posts, y esta vez<ProtectedRoute>mostrará la página de publicaciones.
Actualización de la Interfaz de Usuario con el Usuario Actual
Como ahora sabemos quién ha iniciado sesión mientras usa la aplicación, podemos mostrar el nombre real del usuario en la barra de navegación. También debemos proporcionar una forma de cerrar sesión, añadiendo un botón de "Cerrar sesión".
Necesitamos obtener el objeto de usuario actual del store para poder leer user.name y mostrarlo. Podemos hacerlo obteniendo primero el nombre de usuario actual del slice de autenticación y luego usándolo para buscar el objeto de usuario correcto. Esto parece algo que podríamos querer hacer en varios lugares, así que es un buen momento para escribirlo como un selector reutilizable selectCurrentUser. Podemos ponerlo en usersSlice.ts, pero que importe y dependa de selectCurrentUsername de authSlice.ts:
import { selectCurrentUsername } from '@/features/auth/authSlice'
// omit the rest of the slice and selectors
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
return selectUserById(state, currentUsername)
}
A menudo es útil componer selectores juntos y usar un selector dentro de otro. En este caso, podemos usar tanto selectCurrentUsername como selectUserById juntos.
Al igual que con las otras características que hemos construido, seleccionaremos el estado relevante (el objeto de usuario actual) del store, mostraremos los valores y despacharemos la acción userLoggedOut() cuando hagan clic en el botón "Cerrar sesión":
import { Link } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { userLoggedOut } 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(userLoggedOut())
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
}
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
{navContent}
</section>
</nav>
)
}
De paso, también deberíamos modificar el <AddPostForm> para que utilice el nombre de usuario conectado del estado, en lugar de mostrar un desplegable de selección de usuario. Esto se puede lograr eliminando todas las referencias al campo de entrada postAuthor, y añadiendo un useAppSelector para leer el ID de usuario del authSlice:
export const AddPostForm = () => {
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Removed the `postAuthor` field everywhere in the component
dispatch(postAdded(title, content, userId))
e.currentTarget.reset()
}
Finalmente, tampoco tiene sentido permitir que el usuario actual edite publicaciones de otros usuarios. Podemos actualizar el <SinglePostPage> para mostrar el botón "Editar publicación" solo si el ID del autor coincide con el ID del usuario actual:
import { selectCurrentUsername } from '@/features/auth/authSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
const currentUsername = useAppSelector(selectCurrentUsername)!
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const canEdit = currentUsername === post.user
return (
<section>
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
</section>
)
}
Limpiar otros estados al cerrar sesión
Queda un aspecto más del manejo de autenticación que debemos considerar. Actualmente, si iniciamos sesión como usuario A, creamos una nueva publicación, cerramos sesión y luego volvemos a iniciar como usuario B, veremos tanto las publicaciones de ejemplo iniciales como la nueva publicación.
Esto es "correcto" en el sentido de que Redux funciona según lo previsto con el código que hemos escrito. Actualizamos el estado de la lista de publicaciones en el store de Redux, y no hemos recargado la página, por lo que los mismos datos JS siguen en memoria. Pero en términos de comportamiento de la aplicación, es confuso e incluso podría ser una violación de privacidad. ¿Qué pasa si el usuario B y el usuario A no están conectados entre sí? ¿Y si varias personas comparten el mismo ordenador? No deberían poder ver los datos de los demás al iniciar sesión.
Por ello, sería bueno poder limpiar el estado existente de publicaciones cuando el usuario actual cierra sesión.
Manejar acciones en múltiples slices
Hasta ahora, cada vez que queríamos hacer otra actualización de estado, definíamos un nuevo case reducer de Redux, exportábamos el creador de acciones generado y despachábamos esa acción desde un componente. Podríamos hacer eso aquí. Pero acabaríamos despachando dos acciones Redux separadas consecutivamente, como:
dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())
Cada vez que despachamos una acción, debe ocurrir todo el proceso de actualización del store de Redux: ejecutar el reductor, notificar a los componentes UI suscritos y volver a renderizar los componentes actualizados. Está bien, así funcionan Redux y React, pero despachar dos acciones seguidas suele ser señal de que debemos replantear cómo definimos nuestra lógica.
Ya tenemos la acción userLoggedOut() siendo despachada, pero esa es una acción exportada del slice auth. Sería bueno si pudiéramos escuchar esa misma acción en el slice posts.
Mencionamos antes que ayuda pensar en la acción como "un evento ocurrido en la app", en lugar de "un comando para establecer un valor". Este es un buen ejemplo de eso en la práctica. No necesitamos una acción separada para clearUserData, porque solo ha ocurrido un evento: "el usuario cerró sesión". Solo necesitamos una forma de manejar la acción userLoggedOut en múltiples lugares, para aplicar todas las actualizaciones de estado relevantes simultáneamente.
Usar extraReducers para manejar otras acciones
¡Afortunadamente, podemos hacerlo! createSlice acepta una opción llamada extraReducers, que se puede usar para que el slice escuche acciones definidas en otras partes de la app. Cada vez que se despachen esas otras acciones, este slice también podrá actualizar su propio estado. ¡Eso significa que muchos reductores de slices diferentes pueden responder a la misma acción despachada, y cada slice puede actualizar su propio estado si es necesario!
El campo extraReducers es una función que recibe un parámetro llamado builder. El objeto builder tiene tres métodos adjuntos, cada uno de los cuales permite al slice escuchar otras acciones y hacer sus propias actualizaciones de estado:
-
builder.addCase(actionCreator, caseReducer): escucha un tipo de acción específico -
builder.addMatcher(matcherFunction, caseReducer): escucha cualquiera de múltiples tipos de acciones, usando una función "matcher" de Redux Toolkit para comparar objetos de acción -
builder.addDefaultCase(caseReducer): añade un case reducer que se ejecuta si ninguna otra parte de este slice coincide con la acción (equivalente a un casodefaultdentro de unswitch).
Puedes encadenarlos, como builder.addCase().addCase().addMatcher().addDefaultCase(). Si varios matchers coinciden con la acción, se ejecutarán en el orden en que fueron definidos.
Dado esto, podemos importar la acción userLoggedOut desde authSlice.ts a postsSlice.ts, escuchar esa acción dentro de postsSlice.extraReducers, y devolver un array vacío de publicaciones para reiniciar la lista al cerrar sesión:
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'
import { userLoggedOut } from '@/features/auth/authSlice'
// omit initial state and types
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
// omit postAdded and other case reducers
},
extraReducers: (builder) => {
// Pass the action creator to `builder.addCase()`
builder.addCase(userLoggedOut, (state) => {
// Clear out the list of posts whenever the user logs out
return []
})
},
})
Llamamos a builder.addCase(userLoggedOut, caseReducer). Dentro de ese reducer, podríamos escribir una actualización de estado "mutante", igual que otros case reducers dentro de createSlice. Pero como queremos reemplazar completamente el estado existente, lo más simple es devolver un array vacío para el nuevo estado de publicaciones.
Ahora, si hacemos clic en "Cerrar sesión" y luego iniciamos sesión como otro usuario, la página de "Publicaciones" debería estar vacía. ¡Genial! Hemos limpiado correctamente el estado de publicaciones al cerrar sesión.
reducers y extraReducers?Los campos reducers y extraReducers en createSlice tienen propósitos diferentes:
- Normalmente
reducerses un objeto. Para cada case reducer definido enreducers,createSlicegenerará automáticamente un creador de acciones con el mismo nombre y un tipo de acción para Redux DevTools. Usareducerspara definir nuevas acciones dentro del slice. extraReducersacepta una función con parámetrobuilder, dondebuilder.addCase()ybuilder.addMatcher()manejan otros tipos de acciones sin definir nuevas acciones. UsaextraReducerspara manejar acciones definidas fuera del slice.
Lo que has aprendido
¡Y eso es todo por esta sección! Hemos hecho mucho trabajo. Ahora podemos ver y editar publicaciones individuales, ver autores de cada publicación, añadir reacciones con emojis y rastrear el usuario actual al iniciar y cerrar sesión.
Así se ve nuestra aplicación después de todos estos cambios:
¡Realmente comienza a verse más útil e interesante!
Hemos cubierto mucha información y conceptos en esta sección. Repasemos lo más importante para recordar:
- Cualquier componente React puede usar datos del almacén Redux según sea necesario
- Cualquier componente puede leer cualquier dato del almacén Redux
- Múltiples componentes pueden leer los mismos datos simultáneamente
- Los componentes deben extraer la mínima cantidad de datos necesaria para renderizarse
- Los componentes pueden combinar valores de props, estado local y el almacén Redux para determinar qué UI renderizar. Pueden leer múltiples datos del almacén y transformarlos según necesidades de visualización.
- Cualquier componente puede despachar acciones para actualizar el estado
- Los creadores de acciones en Redux pueden preparar objetos de acción con el contenido adecuado
createSliceycreateActionpueden aceptar un "callback de preparación" que devuelve el payload de la acción- Los IDs únicos y otros valores aleatorios deben incluirse en la acción, no calcularse en el reducer
- Los reducers deben contener la lógica real de actualización de estado
- Los reducers pueden contener cualquier lógica necesaria para calcular el siguiente estado
- Los objetos de acción deben contener solo la información suficiente para describir lo ocurrido
- Puedes escribir funciones "selector" reutilizables para encapsular la lectura de valores del estado Redux
- Los selectores son funciones que reciben el
statede Redux como argumento y devuelven algún dato
- Los selectores son funciones que reciben el
- Las acciones deben considerarse como "eventos que ocurrieron", y múltiples reducers pueden responder a la misma acción despachada
- Las apps normalmente deberían despachar solo una acción a la vez
- Los nombres de case reducers (y acciones) típicamente deben estar en pasado, como
postAdded - Muchos reducers de slice pueden actualizar su propio estado en respuesta a la misma acción
createSlice.extraReducerspermite que los slices escuchen acciones definidas externamente- Los valores de estado pueden restablecerse devolviendo un nuevo valor desde el case reducer como reemplazo, en lugar de mutar el estado existente
¿Qué sigue?
En este punto deberías sentirte cómodo trabajando con datos en el almacén Redux y componentes React. Hasta ahora hemos usado datos que estaban en el estado inicial o añadidos por el usuario. En la Parte 5: Lógica Asíncrona y Obtención de Datos, veremos cómo trabajar con datos provenientes de una API de servidor.