Redux Essentials, Часть 4: Использование данных в Redux
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Использование данных Redux в нескольких React-компонентах
- Организация логики отправки действий (actions)
- Использование селекторов для получения значений состояния
- Написание сложной логики обновления в редюсерах
- Как правильно мыслить о действиях Redux
Введение
В Части 3: Базовый поток данных Redux мы рассмотрели, как начать с пустой настройки проекта Redux+React, добавить новый срез состояния и создать React-компоненты, которые могут читать данные из хранилища Redux и отправлять действия для их обновления. Мы также изучили, как данные проходят через приложение: компоненты отправляют действия, редюсеры обрабатывают действия и возвращают новое состояние, а компоненты читают новое состояние и перерисовывают UI. Кроме того, мы увидели, как создавать "предварительно типизированные" версии хуков useSelector и useDispatch, в которые автоматически подставляются корректные типы хранилища.
Теперь, когда вы знаете основные шаги для написания логики Redux, мы используем эти же шаги для добавления новых функций в нашу ленту социальной сети, которые сделают её полезнее: просмотр отдельного поста, редактирование существующих постов, отображение информации об авторе, временных меток, кнопок реакций и аутентификации.
Напоминаем, что примеры кода сосредоточены на ключевых концепциях и изменениях для каждого раздела. Полные изменения приложения смотрите в проектах CodeSandbox и ветке tutorial-steps-ts в репозитории проекта.
Отображение отдельных постов
Поскольку у нас есть возможность добавлять новые посты в хранилище Redux, мы можем добавить функции, которые по-разному используют эти данные.
Сейчас записи отображаются на главной странице ленты, но если текст слишком длинный, мы показываем только выдержку из содержания. Было бы полезно иметь возможность просматривать отдельную запись на собственной странице.
Создание страницы отдельного поста
Сначала нам нужно добавить новый компонент SinglePostPage в нашу папку фичи posts. Мы используем React Router для отображения этого компонента, когда URL страницы выглядит как /posts/123, где 123 должен быть ID поста, который мы хотим показать.
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>
)
}
При настройке маршрута для рендеринга этого компонента мы укажем ему парсить вторую часть URL как переменную с именем postId, значение которой мы получим с помощью хука useParams.
Получив значение postId, мы можем использовать его внутри функции-селектора, чтобы найти нужный объект поста в хранилище Redux. Мы знаем, что state.posts должен быть массивом всех объектов постов, поэтому можем использовать Array.find() для перебора массива и возврата записи с искомым ID.
Важно отметить, что компонент будет перерисовываться каждый раз, когда значение, возвращаемое из useAppSelector, изменяется на новую ссылку. Компоненты всегда должны стараться выбирать из хранилища минимально необходимый объём данных — это гарантирует, что перерисовка происходит только при реальной необходимости.
Возможно, что в хранилище не найдётся подходящей записи — например, если пользователь ввёл URL напрямую или у нас не загружены нужные данные. В этом случае find() вернёт undefined вместо объекта поста. Наш компонент должен обработать эту ситуацию, показав сообщение "Пост не найден!".
Предположим, у нас есть правильный объект поста в хранилище, useAppSelector вернёт его, и мы сможем использовать его для отображения заголовка и содержимого поста на странице.
Вы могли заметить, что это выглядит довольно похоже на логику в компоненте <PostsList>, где мы перебираем весь массив posts, чтобы показать выдержки постов на главной ленте. Мы могли бы попытаться выделить компонент Post для использования в обоих местах, но уже есть различия в том, как мы показываем выдержку поста и полный пост. Обычно лучше временно сохранять раздельное написание, даже если есть некоторое дублирование, а затем решить, достаточно ли похожи разные участки кода для выделения переиспользуемого компонента.
Добавление маршрута для отдельного поста
Теперь, когда у нас есть компонент <SinglePostPage>, мы можем определить маршрут для его отображения и добавить ссылки на каждый пост в ленте главной страницы.
Попутно стоит также выделить содержимое "главной страницы" в отдельный компонент <PostsMainPage> для улучшения читаемости.
Импортируем PostsMainPage и SinglePostPage в App.tsx и добавим маршрут:
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
Затем в <PostsList> обновим логику рендеринга списка, включив <Link>, который ведёт на конкретный пост:
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>
)
}
И поскольку теперь можно переходить на другие страницы, будет полезно добавить ссылку обратно на главную страницу постов в компонент <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>
)
}
Редактирование постов
Как пользователю, очень неудобно закончить писать пост, сохранить его и осознать, что вы где-то допустили ошибку. Возможность редактировать пост после создания была бы полезной.
Добавим новый компонент <EditPostForm>, который сможет принимать существующий ID поста, читать этот пост из хранилища, позволять пользователю редактировать заголовок и содержимое, а затем сохранять изменения для обновления поста в хранилище.
Обновление записей постов
Сначала нам нужно обновить наш postsSlice, создав новую функцию-редюсер и экшен, чтобы хранилище знало, как именно обновлять посты.
Внутри вызова createSlice() добавим новую функцию в объект reducers. Помните, что название редюсера должно хорошо описывать происходящее, потому что оно будет отображаться как часть строки типа экшена в Redux DevTools. Наш первый редюсер назывался postAdded, поэтому назовём этот postUpdated.
Redux сам по себе не заботится о том, какое имя вы используете для этих функций-редюсеров — он будет работать одинаково, будь оно postAdded, addPost, POST_ADDED или someRandomName.
Тем не менее, мы рекомендуем называть редюсеры в прошедшем времени, как "это произошло": postAdded, потому что мы описываем "событие, произошедшее в приложении".
Чтобы обновить объект поста, нам нужно знать:
-
ID обновляемого поста, чтобы найти правильный объект в состоянии
-
Новые поля
titleиcontent, которые ввёл пользователь
Объекты экшенов Redux обязаны иметь поле type (обычно описательную строку) и могут содержать другие поля с дополнительной информацией. По соглашению мы обычно помещаем дополнительную информацию в поле action.payload, но нам решать, что будет содержать payload — это может быть строка, число, объект, массив или что-то ещё. В данном случае, поскольку у нас есть три части информации, спланируем, что payload будет объектом с этими тремя полями. Это означает, что объект экшена будет выглядеть как {type: 'posts/postUpdated', payload: {id, title, content}}.
По умолчанию создатели действий (action creators), генерируемые createSlice, ожидают один аргумент, значение которого будет помещено в объект действия как action.payload. Поэтому мы можем передать объект, содержащий нужные поля, в качестве аргумента для создателя действия postUpdated. Как и в случае с postAdded, это будет целый объект Post, поэтому мы указываем, что аргумент редьюсера имеет тип action: PayloadAction<Post>.
Мы также знаем, что редьюсер отвечает за определение того, как именно должно обновляться состояние при диспатче действия. Соответственно, редьюсер должен найти нужный пост по ID и обновить именно поля title и content в этом посте.
Наконец, нам нужно экспортировать функцию-создатель действия, сгенерированную createSlice, чтобы UI мог диспатчить новое действие postUpdated при сохранении изменений пользователем.
С учётом всех требований, вот как должна выглядеть наша postsSlice после внесения изменений:
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
Создание формы редактирования поста
Наш новый компонент <EditPostForm> будет похож на <AddPostForm> и <SinglePostPage>, но логика потребует некоторых отличий. Нам нужно получить правильный объект post из хранилища по postId из URL, затем использовать его для инициализации полей ввода в компоненте. При отправке формы мы сохраним изменённые значения заголовка и содержимого обратно в хранилище. Также воспользуемся хуком useNavigate из React Router для перехода на страницу просмотра этого поста после сохранения изменений.
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>
)
}
Обратите внимание, что код, связанный с Redux, здесь относительно минимален. Снова мы читаем значение из хранилища через useAppSelector и диспатчим действие через useAppDispatch при взаимодействии пользователя с интерфейсом.
Как и с SinglePostPage, нам нужно импортировать его в App.tsx и добавить маршрут, который будет рендерить этот компонент с postId в качестве параметра.
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
Также добавим новую ссылку на странице SinglePostPage, ведущую к EditPostForm:
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>
Подготовка полезной нагрузки действий
Мы только что видели, что создатели действий из createSlice обычно ожидают один аргумент, который становится action.payload. Это упрощает наиболее распространённый сценарий, но иногда нам нужно выполнить дополнительные действия для подготовки содержимого объекта действия. В случае с действием postAdded нам нужно сгенерировать уникальный ID для нового поста и убедиться, что полезная нагрузка представляет собой объект вида {id, title, content}.
Сейчас мы генерируем ID и создаём объект полезной нагрузки в React-компоненте, передавая этот объект в postAdded. Но что, если нам потребуется диспатчить это же действие из разных компонентов или логика подготовки полезной нагрузки усложнится? Придётся дублировать эту логику каждый раз, а компоненты будут обязаны точно знать структуру полезной нагрузки для этого действия.
Если действие должно содержать уникальный ID или другое случайное значение, всегда генерируйте его заранее и помещайте в объект действия. Редьюсеры никогда не должны вычислять случайные значения, так как это делает результаты непредсказуемыми.
Если бы мы писали создатель действия postAdded вручную, мы могли бы поместить логику подготовки внутрь него:
// hand-written action creator
function postAdded(title: string, content: string) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
Однако createSlice из Redux Toolkit генерирует создателей действий автоматически. Это сокращает код, но нам всё ещё нужен способ кастомизировать содержимое action.payload.
К счастью, createSlice позволяет определить "подготовительный колбэк" при написании редюсера. Эта функция может принимать несколько аргументов, генерировать случайные значения (например, уникальные ID) и выполнять любую другую синхронную логику для формирования объекта действия. Она должна возвращать объект с полем payload (полезная нагрузка). (Возвращаемый объект может также содержать поле meta для дополнительных описательных значений и поле error — булево значение, указывающее на ошибочность действия.)
В поле reducers внутри createSlice мы можем определить объект вида {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
}
})
Теперь компоненту не нужно заботиться о структуре объекта payload — создатель действия сформирует его корректно. Обновим компонент, передавая title и content как аргументы при диспатче 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()
}
Чтение данных через селекторы
Сейчас несколько компонентов ищут пост по ID, дублируя вызов state.posts.find(). Это избыточный код, и стоит задуматься об устранении дублирования. Кроме того, подход хрупкий — позже мы изменим структуру состояния с постами, и придётся обновлять каждое место с обращением к state.posts. TypeScript поможет отловить ошибки при компиляции, но было бы лучше избежать частых правок компонентов при изменении структуры данных.
Решение — выносить переиспользуемые функции-селекторы в файлы срезов (slices), чтобы компоненты использовали их вместо повторения логики. Так при изменении структуры состояния потребуется правка только в файле среза.
Определение функций-селекторов
Вы уже использовали функции-селекторы при каждом вызове useAppSelector, например useAppSelector( state => state.posts ). Такой селектор определяется инлайн. Поскольку это обычная функция, можно записать его явно:
const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)
Селекторы обычно определяются как отдельные функции в файле среза. Они принимают всё состояние Redux (RootState) первым аргументом и при необходимости — дополнительные параметры.
Вынос селекторов для постов
Компоненту <PostsList> нужен список всех постов, а <SinglePostPage> и <EditPostForm> ищут пост по ID. Экспортируем из postsSlice.ts две функции:
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)
Параметр state в этих селекторах — корневой объект состояния Redux (аналогично инлайн-селекторам в useAppSelector).
Используем их в компонентах:
// 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
}
Примечание: postId из useParams() имеет тип string | undefined, но selectPostById ожидает валидный string. Используем оператор ! в TypeScript для утверждения, что значение не undefined в этом месте кода. (Это допустимо, так как роутинг показывает <EditPostForm> только при наличии ID в URL.)
Будем продолжать практику выноса селекторов в срезы вместо их инлайн-определения внутри useAppSelector в компонентах. Это не обязательно, но рекомендуется!
Эффективное использование селекторов
Инкапсуляция поиска данных через переиспользуемые селекторы — хорошая практика. В идеале компоненты даже не должны знать, где именно в Redux state хранится значение — они просто используют селектор из среза.
Также можно создавать "мемоизированные" селекторы, которые помогают улучшить производительность, оптимизируя перерисовки и пропуская ненужные пересчёты. Мы рассмотрим это подробнее в одной из следующих частей этого руководства.
Однако, как и любая абстракция, это не то, что нужно делать всегда и везде. Написание селекторов означает больше кода для понимания и поддержки. Не стоит писать селекторы для каждого поля вашего состояния. Начните без селекторов и добавляйте их позже, когда обнаружите, что многократно обращаетесь к одним и тем же значениям в разных частях приложения.
Дополнительно: Определение селекторов внутри createSlice
Мы видели, что можем писать селекторы как отдельные функции в файлах срезов. В некоторых случаях это можно сократить, определяя селекторы непосредственно внутри самого 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.
Пользователи и публикации
До сих пор у нас был только один срез состояния. Вся логика определялась в postsSlice.ts, данные хранились в state.posts, и все наши компоненты были связаны с функционалом публикаций. В реальных приложениях обычно бывает несколько срезов состояния и несколько "фиче-папок" (feature folders) для Redux-логики и React-компонентов.
Не может быть "социальной сети" без других участников! Давайте добавим возможность отслеживать список пользователей в нашем приложении и обновим функционал публикаций для использования этих данных.
Добавление среза пользователей
Поскольку концепция "пользователей" отличается от концепции "публикаций", мы хотим разделить код и данные пользователей от кода и данных публикаций. Создадим новую папку features/users и поместим туда файл usersSlice. Как и со срезом публикаций, для начала добавим несколько начальных записей, чтобы у нас были данные для работы.
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)
Пока нам не нужно фактически обновлять данные, поэтому оставим поле reducers пустым объектом. (Вернёмся к этому позже.)
Как и ранее, импортируем usersReducer в наш файл хранилища (store) и добавим его в настройки:
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
}
})
Теперь корневое состояние выглядит как {posts, users}, что соответствует объекту, переданному в аргумент reducer.
Добавление авторов к публикациям
Каждая публикация в нашем приложении была написана одним из пользователей, и при добавлении новой публикации мы должны отслеживать, какой пользователь её создал. Это потребует изменений как в состоянии Redux, так и в компоненте <AddPostForm>.
Сначала обновим существующий тип данных Post, добавив поле user: string, содержащее ID пользователя-автора. Также обновим существующие публикации в initialState, добавив поле post.user с одним из примеров ID пользователей.
Затем обновим наши редюсеры. Подготовительный колбэк (prepare callback) для postAdded должен принимать ID пользователя как аргумент и включать его в действие. Кроме того, нам не нужно включать поле user при обновлении публикации — нам требуются только id изменяемой публикации и новые поля title и content для обновлённого текста. Определим тип PostUpdate, содержащий только эти три поля из Post, и будем использовать его как полезную нагрузку для postUpdated.
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
}
}
}
})
Теперь в нашем компоненте <AddPostForm> мы можем прочитать список пользователей из хранилища с помощью useSelector и отобразить их в выпадающем списке. Затем возьмём ID выбранного пользователя и передадим его в создатель действия postAdded. Заодно добавим валидацию формы, чтобы кнопка "Сохранить публикацию" была активна только при заполненных полях заголовка и содержимого:
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" 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>
)
}
Теперь нам нужен способ отображать имя автора поста в элементах списка и на странице <SinglePostPage>. Поскольку эта информация требуется в нескольких местах, создадим компонент PostAuthor, который принимает ID пользователя, находит соответствующий объект и форматирует имя:
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>
}
Обратите внимание, мы следуем единому шаблону во всех компонентах. Любой компонент, которому нужно читать данные из Redux-хранилища, использует хук useAppSelector и извлекает только необходимые данные. При этом множество компонентов могут одновременно обращаться к одним и тем же данным в хранилище.
Теперь импортируем компонент PostAuthor в PostsList.tsx и SinglePostPage.tsx, отобразив его как <PostAuthor userId={post.user} />. При каждом добавлении поста будет отображаться имя выбранного пользователя.
Дополнительные возможности постов
Теперь мы умеем создавать и редактировать посты. Добавим дополнительную логику для расширения функциональности ленты.
Хранение дат публикаций
Ленты в соцсетях обычно сортируются по времени публикации и отображают его в относительном формате ("5 часов назад"). Для этого нам нужно отслеживать поле date в записях постов.
Аналогично полю post.user, обновим prepare-колбэк для postAdded, гарантируя включение post.date при диспетчеризации действия. Это не внешний параметр — мы всегда используем точную временную метку момента диспетчеризации, которую генерирует сам колбэк.
Действия и состояние Redux должны содержать только простые JS-значения: объекты, массивы, примитивы. Не помещайте экземпляры классов, функции, Date/Map/Set или другие несериализуемые значения в Redux!
Поскольку мы не можем поместить экземпляр Date в хранилище, будем хранить post.date как строку с временной меткой. Добавим его в начальное состояние (используя date-fns для вычитания нескольких минут из текущей даты) и в каждый новый пост через 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
}
})
Как и с авторами постов, нам нужно отображать относительное время публикации в <PostsList> и <SinglePostPage>. Создадим компонент <TimeAgo> для форматирования временной метки. Библиотеки вроде date-fns предоставляют полезные утилиты:
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>
)
}
Сортировка списка постов
Сейчас <PostsList> отображает посты в том же порядке, в котором они хранятся в Redux. В нашем примере самые старые посты идут первыми, а новые добавляются в конец массива, поэтому они всегда оказываются внизу страницы.
Обычно в соцсетях новые посты отображаются сверху, а для просмотра старых нужно прокручивать ленту вниз. Хотя данные в хранилище упорядочены от старых к новым, мы можем изменить порядок в компоненте <PostsList>. Технически можно просто перевернуть массив state.posts, но надежнее выполнить сортировку.
Поскольку array.sort() мутирует исходный массив, создадим копию state.posts и отсортируем её. Используем временные метки post.date для правильной сортировки:
// 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
)
})
Кнопки реакций на посты
Сейчас наши посты выглядят скучно. Сделаем их интереснее, добавив возможность ставить реакции с помощью эмодзи! 🎉
Мы добавим строку с кнопками реакций-эмодзи внизу каждого поста в компонентах <PostsList> и <SinglePostPage>. Каждый раз, когда пользователь нажимает одну из кнопок реакций, нам нужно будет обновить соответствующий счётчик для этого поста в хранилище Redux. Поскольку данные счётчиков реакций хранятся в Redux, переключение между разными разделами приложения будет показывать одинаковые значения в любом компоненте, использующем эти данные.
Отслеживание данных реакций в постах
У нас пока нет поля post.reactions в данных, поэтому нужно обновить объекты постов в initialState и нашу функцию обратного вызова prepare для postAdded, чтобы каждый пост содержал эти данные в виде reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0}.
Затем мы можем определить новый редюсер, который будет обрабатывать обновление счётчика реакций при нажатии пользователем кнопки.
Как и при редактировании постов, нам нужно знать ID поста и какую именно кнопку реакции нажал пользователь. Наш action.payload будет объектом вида {id, reaction}. Редюсер сможет найти нужный пост и обновить соответствующее поле реакций.
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
Как мы уже видели, createSlice позволяет писать "мутирующую" логику в редюсерах. Если бы мы не использовали createSlice и библиотеку Immer, строка existingPost.reactions[reaction]++ действительно изменяла бы существующий объект post.reactions, что могло бы вызвать ошибки из-за нарушения правил иммутабельности. Но поскольку мы используем createSlice, мы можем писать эту сложную логику обновления более простым способом, а Immer преобразует её в безопасное неизменяемое обновление.
Обратите внимание: наш объект экшена содержит только минимальную информацию, необходимую для описания произошедшего. Мы знаем, какой пост нужно обновить и какую реакцию выбрали. Мы могли бы вычислить новое значение счётчика и поместить его в экшен, но всегда лучше хранить экшены максимально компактными, а вычисления состояния выполнять в редюсере. Это также означает, что редюсеры могут содержать сколь угодно сложную логику для расчёта нового состояния. Фактически, логика обновления состояния должна находиться в редюсере! Это помогает избежать дублирования логики в разных компонентах и ситуаций, когда UI-слой может не иметь актуальных данных.
При использовании Immer вы можете либо "мутировать" существующий объект состояния, либо возвращать новое значение самостоятельно, но не одновременно и то, и другое. Подробнее см. в документации Immer разделы Типичные ошибки и Возврат новых данных.
Отображение кнопок реакций
Как и с авторами постов и временными метками, мы хотим использовать это везде, где отображаются посты, поэтому создадим компонент <ReactionButtons>, принимающий post как пропс. При нажатии кнопки мы будем диспатчить экшен reactionAdded с именем соответствующего эмодзи.
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>
}
Теперь при каждом нажатии кнопки реакции соответствующий счётчик должен увеличиваться. Если переходить по разным разделам приложения, мы будем видеть корректные значения счётчиков при просмотре этого поста, даже если нажали кнопку реакции в <PostsList>, а затем посмотрели пост отдельно на <SinglePostPage>. Это происходит потому, что каждый компонент читает одни и те же данные поста из хранилища Redux.
Добавление входа пользователя
В этом разделе нам осталось добавить ещё одну функциональность.
Сейчас мы просто выбираем, какой пользователь пишет каждый пост в <AddPostForm>. Чтобы добавить реалистичности, стоит реализовать вход пользователя в приложение. Тогда мы будем знать, кто именно пишет посты (что также пригодится для других функций в будущем).
Поскольку это небольшое учебное приложение, мы не будем реализовывать настоящую проверку аутентификации (и цель здесь — изучить возможности Redux, а не создать реальную систему аутентификации). Вместо этого мы просто покажем список имён пользователей и позволим текущему пользователю выбрать одно из них.
В этом примере мы просто добавим срез auth, который отслеживает state.auth.username, чтобы знать, кто вошёл в систему. Затем мы сможем использовать эту информацию при добавлении поста, автоматически указывая правильный ID пользователя.
Добавление среза аутентификации
Первый шаг — создание authSlice и его добавление в хранилище. Это уже знакомый нам паттерн: определить начальное состояние, написать срез с парой редюсеров для обработки входа и выхода, добавить редюсер среза в хранилище.
В нашем случае состояние аутентификации — это просто имя текущего вошедшего пользователя, которое мы сбросим в null при выходе.
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
}
})
Добавление страницы входа
Сейчас главный экран приложения — компонент <Posts> со списком постов и формой добавления. Мы изменим это поведение. Вместо этого пользователь сначала должен увидеть экран входа и получить доступ к постам только после авторизации.
Сначала создадим компонент <LoginPage>. Он будет читать список пользователей из хранилища, показывать их в выпадающем списке и отправлять действие userLoggedIn при отправке формы. Также мы выполним переход на маршрут /posts, чтобы после входа увидеть <PostsMainPage>:
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>
)
}
Далее обновим маршрутизацию в компоненте <App>. Нам нужно показывать <LoginPage> для корневого маршрута / и перенаправлять неавторизованные попытки доступа на другие страницы обратно на экран входа.
Один из распространённых способов — создать компонент "защищённого маршрута", который принимает React-компоненты как children, проверяет авторизацию и показывает дочерние компоненты только при её наличии. Мы добавим компонент <ProtectedRoute>, который читает значение state.auth.username и использует его для проверки, затем обернём весь раздел маршрутов, связанных с постами, в тот <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
Теперь мы должны увидеть работу обеих сторон системы аутентификации:
-
При попытке доступа к
/postsбез авторизации компонент<ProtectedRoute>перенаправит обратно на/и покажет<LoginPage> -
Когда пользователь войдёт, мы отправим
userLoggedIn()для обновления состояния Redux, затем принудительно перейдём на/posts, и на этот раз<ProtectedRoute>отобразит страницу постов.
Обновление UI с учётом текущего пользователя
Теперь, зная, кто вошёл в систему, мы можем показать настоящее имя пользователя в навигационной панели. Также стоит дать ему возможность выхода, добавив кнопку "Выйти".
Нам нужно получить текущий объект пользователя из хранилища, чтобы прочитать user.name для отображения. Сначала мы получим имя из среза аутентификации, затем используем его для поиска объекта пользователя. Поскольку это может понадобиться в нескольких местах, напишем переиспользуемый селектор selectCurrentUser. Разместим его в usersSlice.ts, но импортируем и будем использовать selectCurrentUsername из 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)
}
Часто полезно комбинировать селекторы и использовать один внутри другого. В этом случае мы можем использовать и selectCurrentUsername, и selectUserById вместе.
Как и в других функциях, которые мы создавали, мы выберем из хранилища релевантное состояние (объект текущего пользователя), отобразим значения и отправим действие userLoggedOut() при клике на кнопку "Выйти":
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>
)
}
Пока мы здесь, также стоит перевести <AddPostForm> на использование имени вошедшего пользователя из состояния вместо выпадающего списка выбора пользователя. Это можно сделать, убрав все упоминания поля ввода postAuthor и добавив useAppSelector для чтения ID пользователя из 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()
}
Наконец, не имеет смысла разрешать текущему пользователю редактировать посты, созданные другими пользователями. Мы можем обновить <SinglePostPage>, чтобы кнопка "Редактировать пост" отображалась только если ID автора поста совпадает с ID текущего пользователя:
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>
)
}
Очистка состояния при выходе
Есть ещё один аспект обработки авторизации, который нужно рассмотреть. Сейчас, если мы войдём как пользователь A, создадим новый пост, выйдем, а затем войдём как пользователь B, мы увидим как начальные примеры постов, так и новый пост.
Это "корректно" в том смысле, что Redux работает так, как задумано для написанного нами кода. Мы обновили состояние списков постов в хранилище Redux и не обновляли страницу, поэтому те же JS-данные всё ещё в памяти. Но с точки зрения поведения приложения это сбивает с толку и может нарушать конфиденциальность. Что если пользователь B и пользователь A не связаны друг с другом? Что если несколько человек используют один компьютер? Они не должны видеть данные друг друга при входе.
Учитывая это, было бы хорошо очистить существующее состояние постов при выходе текущего пользователя.
Обработка действий в нескольких слайсах
До сих пор, когда нам требовалось обновить состояние, мы определяли новый редюсер в Redux, экспортировали сгенерированный создатель действия и диспатчили это действие из компонента. Мы могли бы поступить так и здесь. Но тогда пришлось бы диспатчить два отдельных действия Redux подряд:
dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())
Каждый раз при диспатче действия запускается весь процесс обновления хранилища Redux — выполнение редюсера, уведомление подписанных компонентов и их перерендеринг. Это нормально, так работают Redux и React, но диспатч двух действий подряд обычно указывает на необходимость пересмотреть логику.
У нас уже есть действие userLoggedOut(), которое диспатчится, но это действие экспортировано из слайса auth. Было бы удобно просто прослушивать его и в слайсе posts.
Ранее мы упоминали, что полезно рассматривать действие как "событие, произошедшее в приложении", а не "команду для установки значения". Это хороший пример на практике. Нам не нужно отдельное действие clearUserData, потому что произошло только одно событие — "пользователь вышел". Нам просто нужен способ обработать действие userLoggedOut в нескольких местах, чтобы применить все соответствующие обновления состояния одновременно.
Использование extraReducers для обработки других действий
К счастью, мы можем это сделать! createSlice принимает опцию extraReducers, которая позволяет слайсу прослушивать действия, определённые в других частях приложения. Каждый раз при диспатче таких действий этот слайс также может обновлять своё состояние. Это означает, что многие редюсеры могут одновременно реагировать на одно диспатченное действие, и каждый слайс может обновить своё состояние при необходимости!
Поле extraReducers — это функция, принимающая параметр builder. Объект builder имеет три метода, каждый из которых позволяет слайсу прослушивать другие действия и выполнять свои обновления состояния:
-
builder.addCase(actionCreator, caseReducer): прослушивает один конкретный тип действия -
builder.addMatcher(matcherFunction, caseReducer): прослушивает несколько типов действий, используя функцию-матчер Redux Toolkit для сравнения объектов действий -
builder.addDefaultCase(caseReducer): добавляет редьюсер, который выполняется, если ни один другой кейс в этом слайсе не совпал с экшеном (эквивалент блокаdefaultвнутриswitch).
Можно объединять их в цепочку: builder.addCase().addCase().addMatcher().addDefaultCase(). Если несколько матчеров совпадают с экшеном, они выполняются в порядке их определения.
Учитывая это, мы можем импортировать экшен userLoggedOut из authSlice.ts в postsSlice.ts, прослушивать его внутри postsSlice.extraReducers и возвращать пустой массив постов для сброса списка при выходе из системы:
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 []
})
},
})
Мы вызываем builder.addCase(userLoggedOut, caseReducer). Внутри этого редьюсера мы могли бы написать "мутирующее" обновление состояния, как в любом другом редьюсере внутри createSlice. Но поскольку мы хотим полностью заменить текущее состояние, проще всего просто вернуть пустой массив в качестве нового состояния постов.
Теперь, если мы нажмем кнопку "Log Out" и войдем под другим пользователем, страница "Posts" будет пустой. Отлично! Мы успешно очистили состояние постов при выходе из системы.
reducers и extraReducers?Поля reducers и extraReducers в createSlice служат разным целям:
reducersобычно является объектом. Для каждого редьюсера, определенного в объектеreducers,createSliceавтоматически создаст action creator с тем же именем и строку типа экшена для отображения в Redux DevTools. Используйтеreducersдля определения новых экшенов внутри слайса.extraReducersпринимает функцию с параметромbuilder, где методыbuilder.addCase()иbuilder.addMatcher()используются для обработки других типов экшенов без создания новых экшенов. ИспользуйтеextraReducersдля обработки экшенов, определенных вне слайса.
Итоги изученного
На этом раздел завершен! Мы проделали большую работу. Теперь мы можем просматривать и редактировать отдельные посты, видеть авторов каждого поста, добавлять реакции с эмодзи и отслеживать текущего пользователя при входе и выходе из системы.
Вот как выглядит наше приложение после всех изменений:
Оно действительно становится более полезным и интересным!
Мы рассмотрели много информации и концепций в этом разделе. Давайте вспомним ключевые моменты:
- Любой React-компонент может использовать данные из Redux-хранилища по мере необходимости
- Любой компонент может читать любые данные из Redux-хранилища
- Несколько компонентов могут читать одни и те же данные, даже одновременно
- Компоненты должны извлекать минимально необходимый объем данных для своего рендеринга
- Компоненты могут комбинировать значения из пропсов, локального состояния и Redux-хранилища для определения необходимого UI. Они могут читать несколько фрагментов данных из хранилища и преобразовывать их для отображения.
- Любой компонент может диспатчить экшены для обновления состояния
- Создатели экшенов Redux могут формировать объекты экшенов с правильным содержимым
createSliceиcreateActionмогут принимать "подготовительный колбэк", возвращающий полезную нагрузку экшена- Уникальные ID и другие случайные значения должны помещаться в экшен, а не вычисляться в редьюсере
- Редьюсеры должны содержать фактическую логику обновления состояния
- Редьюсеры могут содержать любую логику, необходимую для вычисления следующего состояния
- Объекты экшенов должны содержать ровно столько информации, чтобы описать произошедшее
- Можно создавать переиспользуемые "селекторные" функции для инкапсуляции чтения значений из Redux-состояния
- Селекторы — это функции, принимающие Redux
stateи возвращающие данные
- Селекторы — это функции, принимающие Redux
- Экшены следует воспринимать как описание "произошедших событий", и многие редьюсеры могут реагировать на один диспатч экшена
- Приложения обычно должны диспатчить только один экшен за раз
- Названия кейсов редьюсеров (и экшенов) обычно должны быть в прошедшем времени, например
postAdded - Многие редьюсеры срезов могут обновлять свое состояние в ответ на один экшен
createSlice.extraReducersпозволяет срезам реагировать на экшены, определенные вне среза- Значения состояния можно сбрасывать, возвращая новое значение из кейса редьюсера вместо мутации существующего состояния
Что дальше?
К этому моменту вы должны уверенно работать с данными в Redux-хранилище и React-компонентах. Пока мы использовали только данные из начального состояния или добавленные пользователем. В Части 5: Асинхронная логика и получение данных мы рассмотрим работу с данными, поступающими с серверного API.