Redux Essentials, Часть 6: Производительность, Нормализация данных и Реактивная логика
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Как создавать мемоизированные функции-селекторы с помощью
createSelector - Паттерны для оптимизации производительности рендеринга компонентов
- Как использовать
createEntityAdapterдля хранения и обновления нормализованных данных - Как использовать
createListenerMiddlewareдля реактивной логики
- Завершение Части 5 для понимания потока загрузки данных
Введение
В Части 5: Асинхронная логика и загрузка данных мы рассмотрели, как писать асинхронные thunks для получения данных из серверного API и паттерны обработки состояний загрузки асинхронных запросов.
В этом разделе мы рассмотрим оптимизированные паттерны для обеспечения хорошей производительности нашего приложения и техники автоматической обработки распространённых обновлений данных в хранилище. Также мы изучим, как писать реактивную логику, которая реагирует на диспатч экшенов.
До сих пор большая часть нашей функциональности была сосредоточена вокруг фичи posts. Мы добавим несколько новых разделов приложения. После их добавления мы рассмотрим конкретные детали нашей реализации, обсудим слабые места текущего решения и способы его улучшения.
Добавление возможностей для пользователей
Добавление страниц пользователей
Мы получаем список пользователей из нашего тестового API и можем выбирать пользователя как автора при добавлении нового поста. Но социальному приложению нужна возможность просматривать страницу конкретного пользователя и все его посты. Давайте добавим страницу для отображения списка всех пользователей и ещё одну — для показа всех постов конкретного пользователя.
Начнём с добавления нового компонента <UsersList>. Он следует стандартному паттерну: чтение данных из хранилища через useSelector и преобразование массива для отображения списка пользователей со ссылками на их страницы:
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
import { selectAllUsers } from './usersSlice'
export const UsersList = () => {
const users = useAppSelector(selectAllUsers)
const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))
return (
<section>
<h2>Users</h2>
<ul>{renderedUsers}</ul>
</section>
)
}
Также добавим компонент <UserPage>, который похож на <SinglePostPage>: принимает параметр userId из роутера и отображает все посты этого пользователя. Следуя нашему стандартному паттерну, сначала добавим селектор selectPostsByUser в postsSlice.ts:
// omit rest of the file
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ This seems suspicious! See more details below
return allPosts.filter(post => post.user === userId)
}
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
import { Link, useParams } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'
import { selectPostsByUser } from '@/features/posts/postsSlice'
import { selectUserById } from './usersSlice'
export const UserPage = () => {
const { userId } = useParams()
const user = useAppSelector(state => selectUserById(state, userId!))
const postsForUser = useAppSelector(state =>
selectPostsByUser(state, userId!)
)
if (!user) {
return (
<section>
<h2>User not found!</h2>
</section>
)
}
const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))
return (
<section>
<h2>{user.name}</h2>
<ul>{postTitles}</ul>
</section>
)
}
Обратите внимание: внутри selectPostsByUser мы используем allPosts.filter(). Это на самом деле неправильный паттерн! Почему — увидим чуть позже.
В нашем usersSlice уже доступны селекторы selectAllUsers и selectUserById, поэтому мы можем просто импортировать и использовать их в компонентах.
Как мы уже видели, можно брать данные из одного вызова useSelector или из пропсов и использовать их для определения того, что читать из хранилища в другом вызове useSelector.
Как обычно, добавим маршруты для этих компонентов в <App>:
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
Также добавим новую вкладку в <Navbar> со ссылкой на /users, чтобы можно было перейти к <UsersList>:
export const Navbar = () => {
// omit other logic
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
// omit other rendering
}
Теперь мы можем переходить на страницу каждого пользователя и видеть список только его постов.
Отправка запросов авторизации на сервер
Сейчас наши <LoginPage> и authSlice просто диспатчат клиентские экшены Redux для отслеживания текущего имени пользователя. На практике нам действительно нужно отправлять запрос на авторизацию на сервер. Как мы делали с постами и пользователями, преобразуем обработку входа и выхода в асинхронные thunks.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
interface AuthState {
username: string | null
}
export const login = createAppAsyncThunk(
'auth/login',
async (username: string) => {
await client.post('/fakeApi/login', { username })
return username
}
)
export const logout = createAppAsyncThunk('auth/logout', async () => {
await client.post('/fakeApi/logout', {})
})
const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
// Remove the reducer definitions
reducers: {},
extraReducers: builder => {
// and handle the thunk actions instead
builder
.addCase(login.fulfilled, (state, action) => {
state.username = action.payload
})
.addCase(logout.fulfilled, state => {
state.username = null
})
}
})
// Removed the exported actions
export default authSlice.reducer
Параллельно обновим <Navbar> и <LoginPage>, чтобы они импортировали и диспатчили новые thunks вместо предыдущих создателей экшенов:
import { Link } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { logout } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
import { UserIcon } from './UserIcon'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'
import { login } from './authSlice'
// omit types
export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()
const username = e.currentTarget.elements.username.value
await dispatch(login(username))
navigate('/posts')
}
Поскольку создатель действия userLoggedOut использовался в postsSlice, мы можем обновить его для прослушивания logout.fulfilled вместо этого:
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
// Import this thunk instead
import { logout } from '@/features/auth/authSlice'
// omit types and setup
const postsSlice = createSlice({
name,
initialState,
reducers: {
/* omitted */
},
extraReducers: builder => {
builder
// switch to handle the thunk fulfilled action
.addCase(logout.fulfilled, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// omit other cases
}
})
Добавление уведомлений
Ни одно социальное приложение не будет полным без уведомлений, которые сообщают нам о новых сообщениях, комментариях или реакциях к нашим постам.
В реальном приложении наш клиент постоянно общался бы с сервером, который отправлял бы обновления при каждом событии. Поскольку это учебный пример, мы сымитируем этот процесс, добавив кнопку для загрузки уведомлений из нашего тестового API. У нас также нет других реальных пользователей, поэтому тестовый API будет просто генерировать случайные уведомления при каждом запросе. (Помните, что наша цель — изучить использование самого Redux.)
Слайс уведомлений
Поскольку это новая часть приложения, первым шагом будет создание слайса для уведомлений и асинхронного thunk для их загрузки из API. Чтобы сделать уведомления реалистичными, мы включим временную метку последнего уведомления из состояния. Это позволит нашему тестовому серверу генерировать уведомления новее этой метки.
import { createSlice } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
export interface ServerNotification {
id: string
date: string
message: string
user: string
}
export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
const allNotifications = selectAllNotifications(thunkApi.getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)
const initialState: ServerNotification[] = []
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export default notificationsSlice.reducer
export const selectAllNotifications = (state: RootState) => state.notifications
Как и с другими слайсами, мы импортируем notificationsReducer в store.ts и добавляем его в вызов configureStore().
Мы создали асинхронный thunk fetchNotifications для получения новых уведомлений с сервера. В запросе мы используем временную метку последнего уведомления, чтобы сервер возвращал только действительно новые уведомления.
Мы знаем, что получим массив уведомлений, поэтому можем передать их отдельными аргументами в state.push(), и массив добавит каждый элемент. Также мы хотим отсортировать их так, чтобы самые новые уведомления были первыми (на случай, если сервер отправит их в неправильном порядке). (Напоминаем: array.sort() всегда изменяет исходный массив — это безопасно только потому, что мы используем createSlice с Immer внутри.)
Аргументы Thunk
Если вы посмотрите на наш thunk fetchNotifications, там есть кое-что новое. Давайте обсудим аргументы thunk.
Мы уже видели, что при вызове thunk мы можем передать аргумент, например: dispatch(addPost(newPost)). Для createAsyncThunk можно передать только один аргумент, который станет первым аргументом колбэка создания полезной нагрузки. Если ничего не передать, этот аргумент будет undefined.
Вторым аргументом колбэка является объект thunkAPI, содержащий полезные функции и данные:
-
dispatchиgetState: реальные методыdispatchиgetStateиз нашего Redux-хранилища. Их можно использовать внутри thunk для диспетчеризации дополнительных действий или получения актуального состояния хранилища (например, после диспетчеризации другого действия). -
extra: "дополнительный аргумент", передаваемый в middleware thunk при создании хранилища. Обычно это обёртка API — набор функций для выполнения запросов к серверу, что позволяет не хранить URL и логику запросов прямо в thunk. -
requestId: уникальный случайный ID для данного вызова thunk. Полезен для отслеживания статуса запроса. -
signal: ФункцияAbortController.signal, позволяющая отменить выполняющийся запрос. -
rejectWithValue: утилита для кастомизации содержимого действияrejectedпри ошибке в thunk.
(Если вы пишете thunk вручную без createAsyncThunk, функция получит (dispatch, getState) как отдельные аргументы, а не объединённые в один объект.)
Подробнее об этих аргументах и обработке отмены задач (thunks) и запросов см. в справочнике по API createAsyncThunk.
В данном случае нам нужен доступ к аргументу thunkApi, который всегда передаётся вторым. Это означает, что нам нужно задать какое-то имя для первого аргумента, даже если мы ничего не передаём при диспетчеризации задачи и не используем его внутри колбэка полезной нагрузки. Поэтому просто назовём его _unused.
Далее мы знаем, что список уведомлений находится в состоянии Redux, причём самые свежие должны идти первыми. Мы можем вызвать thunkApi.getState() для чтения состояния и использовать селектор selectAllNotifications, чтобы получить только массив уведомлений. Поскольку массив отсортирован от новых к старым, мы можем извлечь последнее уведомление через деструктуризацию массива.
Добавление списка уведомлений
Теперь, когда создан notificationsSlice, добавим компонент <NotificationsList>. Он должен читать список уведомлений из хранилища и форматировать их, включая отображение давности каждого уведомления и его автора. У нас уже есть компоненты <PostAuthor> и <TimeAgo>, которые могут выполнить форматирование, поэтому их можно переиспользовать. Однако <PostAuthor> включает префикс "от ", который здесь неуместен — модифицируем его, добавив проп showPrefix со значением по умолчанию true, и явно отключим префиксы в данном случае.
interface PostAuthorProps {
userId: string
showPrefix?: boolean
}
export const PostAuthor = ({ userId, showPrefix = true }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))
return (
<span>
{showPrefix ? 'by ' : null}
{author?.name ?? 'Unknown author'}
</span>
)
}
import { useAppSelector } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from '@/features/posts/PostAuthor'
import { selectAllNotifications } from './notificationsSlice'
export const NotificationsList = () => {
const notifications = useAppSelector(selectAllNotifications)
const renderedNotifications = notifications.map(notification => {
return (
<div key={notification.id} className="notification">
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
Также обновим <Navbar>, добавив вкладку "Уведомления" и новую кнопку для запроса уведомлений:
// omit several imports
import { logout } from '@/features/auth/authSlice'
import { fetchNotifications } from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit user details */}
</div>
)
}
// omit other rendering
}
Наконец, нам нужно обновить App.tsx, добавив маршрут "Уведомления", чтобы мы могли переходить к нему:
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'
function App() {
return (
// omit all the outer router setup
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
<Route path="/notifications" element={<NotificationsList />} />
</Routes>
)
}
Вот как сейчас выглядит вкладка "Уведомления":

Отображение новых уведомлений
При каждом нажатии "Обновить уведомления" в список добавляются новые записи. В реальном приложении они могут приходить с сервера, пока мы работаем с другими частями интерфейса. Аналогичное поведение можно имитировать, нажимая "Обновить уведомления" при просмотре <PostsList> или <UserPage>.
Однако сейчас мы не знаем, сколько новых уведомлений пришло, и при постоянном обновлении может накопиться много непрочитанных. Добавим логику для отслеживания прочитанных и "новых" уведомлений. Это позволит отображать счётчик "Непрочитанных" как бейдж на вкладке "Уведомления" в навбаре и выделять новые уведомления цветом.
Отслеживание статуса уведомлений
Объекты Notification, возвращаемые нашим фейковым API, выглядят как {id, date, message, user}. Понятия "новое" или "непрочитанное" существуют только на клиенте. Учтя это, переработаем notificationsSlice.
Сначала создадим тип ClientNotification, расширяющий ServerNotification двумя новыми полями. Затем при получении новой партии уведомлений с сервера будем добавлять эти поля со значениями по умолчанию.
Далее добавим редюсер для отметки всех уведомлений как прочитанных и логику пометки существующих уведомлений как "не новых".
Наконец, добавим селектор для подсчёта непрочитанных уведомлений в хранилище:
// omit imports
export interface ServerNotification {
id: string
date: string
message: string
user: string
}
export interface ClientNotification extends ServerNotification {
read: boolean
isNew: boolean
}
// omit thunk
const initialState: ClientNotification[] = []
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))
state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
state.push(...notificationsWithMetadata)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Отметка уведомлений как прочитанных
Мы хотим помечать эти уведомления как прочитанные при каждом рендере компонента <NotificationsList>, будь то переход на вкладку уведомлений или получение новых уведомлений во время её просмотра. Для этого мы можем диспатчить allNotificationsRead при каждом ререндере. Чтобы избежать мелькания устаревших данных, мы выполним диспатч в хуке useLayoutEffect. Также добавим специальный класс для новых записей в списке уведомлений, чтобы их подсвечивать:
import { useLayoutEffect } from 'react'
import classnames from 'classnames'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { TimeAgo } from '@/components/TimeAgo'
import { PostAuthor } from '@/features/posts/PostAuthor'
import {
allNotificationsRead,
selectAllNotifications
} from './notificationsSlice'
export const NotificationsList = () => {
const dispatch = useAppDispatch()
const notifications = useAppSelector(selectAllNotifications)
useLayoutEffect(() => {
dispatch(allNotificationsRead())
})
const renderedNotifications = notifications.map(notification => {
const notificationClassname = classnames('notification', {
new: notification.isNew
})
return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})
return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}
Это работает, но демонстрирует неожиданное поведение: при появлении новых уведомлений (после переключения вкладки или получения данных с API) диспатчится два экшена "notifications/allNotificationsRead". Почему так происходит?
Предположим, мы получили уведомления при просмотре <PostsList>, затем переключились на вкладку "Уведомления". После первого рендера <NotificationsList> запустится колбэк useLayoutEffect, который диспатчит allNotificationsRead. Редьюсер notificationsSlice обновит записи в хранилище, создав новый массив state.notifications. Это вызовет повторный рендер компонента, так как useSelector вернёт новый массив.
При втором рендере хук useLayoutEffect снова выполнится и диспатчит allNotificationsRead. Редьюсер также запустится, но на этот раз данные не изменятся, поэтому состояние слайса и корневого хранилища останутся прежними, и компонент не перерендерится.
Можно избежать второго диспатча (например, выполняя его только при монтировании компонента или при изменении количества уведомлений), но текущее поведение не причиняет вреда, поэтому оставим как есть.
Это демонстрирует важный принцип: диспатч экшена не всегда приводит к изменению состояния. Помните, редьюсеры самостоятельно решают, нужно ли обновлять состояние, и вариант "ничего не менять" — вполне допустим.
Вот как выглядит вкладка уведомлений после реализации логики "новые/прочитанные":

Отображение непрочитанных уведомлений
Последнее, что нужно сделать — добавить бейдж с количеством непрочитанных уведомлений на вкладку "Уведомления" в навбаре, который будет виден при просмотре других разделов:
// omit other imports
import {
fetchNotifications,
selectUnreadNotificationsCount
} from '@/features/notifications/notificationsSlice'
export const Navbar = () => {
const dispatch = useAppDispatch()
const username = useAppSelector(selectCurrentUsername)
const user = useAppSelector(selectCurrentUser)
const numUnreadNotifications = useAppSelector(selectUnreadNotificationsCount)
const isLoggedIn = !!user
let navContent: React.ReactNode = null
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}
let unreadNotificationsBadge: React.ReactNode | undefined
if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}
navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit button */}
</div>
)
}
// omit other rendering
}
Оптимизация производительности рендеринга
Наше приложение функционально, но содержит недостатки в логике ререндеров компонентов. Исследуем эти проблемы и рассмотрим способы оптимизации производительности.
Анализ поведения рендеринга
Используем React DevTools Profiler для визуализации ререндеров. Откройте страницу пользователя (<UserPage>), в DevTools перейдите на вкладку "Profiler", нажмите кнопку записи (круг в левом верхнем углу), затем в приложении кликните "Refresh Notifications". Остановите запись и проанализируйте график:

Ререндер <Navbar> ожидаем — он отображает обновлённый бейдж уведомлений. Но почему перерендерилась <UserPage>?
Проверив последние экшены в Redux DevTools, видим, что обновлялось только состояние уведомлений. Поскольку <UserPage> не использует эти данные, ререндер не должен происходить. Проблема кроется либо в компоненте, либо в используемых селекторах.
Компонент <UserPage> читает список постов из хранилища через selectPostsByUser. Если внимательнее посмотреть на selectPostsByUser, обнаруживается конкретная проблема:
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ WRONG - this _always_ creates a new array reference!
return allPosts.filter(post => post.user === userId)
}
Мы знаем, что useSelector перезапускается при каждом диспетчеризации действия и заставляет компонент перерисовываться, если возвращается новое ссылочное значение.
Мы вызываем filter() внутри функции-селектора, чтобы возвращать только список постов, принадлежащих данному пользователю.
К сожалению, это означает, что useSelector всегда возвращает новую ссылку на массив для этого селектора, и наш компонент будет перерисовываться после каждого действия, даже если данные постов не изменились!.
Это распространённая ошибка в приложениях Redux. Поэтому React-Redux в режиме разработки проверяет селекторы, которые случайно всегда возвращают новые ссылки. Если открыть инструменты разработчика в браузере и перейти в консоль, вы должны увидеть предупреждение:
Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized:
at UserPage (http://localhost:5173/src/features/users/UserPage.tsx)
В большинстве случаев ошибка сообщает имя переменной селектора. В этом случае в сообщении нет конкретного имени, поскольку мы используем анонимную функцию внутри useAppSelector. Но знание, что это в <UserPage>, сужает поиск.
В этом примере приложения это не критично: компонент <UserPage> небольшой, действий диспетчеризуется мало. Однако в реальных приложениях это может стать серьёзной проблемой производительности с варьирующимся влиянием в зависимости от структуры. Лишние перерисовки компонентов — распространённая проблема, требующая исправления.
Мемоизация функций-селекторов
Нам нужен механизм, пересчитывающий новый отфильтрованный массив только при изменении state.posts или userId. Если они не изменились, следует возвращать ту же ссылку на отфильтрованный массив.
Эта концепция называется "мемоизацией". Мы сохраняем предыдущие входные данные и результат вычислений, возвращая сохранённый результат при идентичных входных данных вместо пересчёта.
До сих пор мы писали селекторы как обычные функции, используя их для избежания дублирования кода чтения данных. Было бы полезно сделать их мемоизированными для улучшения производительности.
Reselect — библиотека для создания мемоизированных селекторов, специально разработанная для Redux. Её функция createSelector генерирует мемоизированные селекторы, пересчитывающие результат только при изменении входных данных. Redux Toolkit предоставляет createSelector, поэтому она уже доступна.
Перепишем selectPostsByUser как мемоизированную функцию с помощью createSelector:
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'
// omit slice logic
export const selectAllPosts = (state: RootState) => state.posts.posts
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)
export const selectPostsByUser = createSelector(
// Pass in one or more "input selectors"
[
// we can pass in an existing selector function that
// reads something from the root `state` and returns it
selectAllPosts,
// and another function that extracts one of the arguments
// and passes that onward
(state: RootState, userId: string) => userId
],
// the output function gets those values as its arguments,
// and will run when either input value changes
(posts, userId) => posts.filter(post => post.user === userId)
)
createSelector требует один или несколько "входных селекторов" (в массиве или отдельных аргументах) и "функцию вывода", вычисляющую результат.
При вызове selectPostsByUser(state, userId) createSelector передаст аргументы во входные селекторы. Их возвращаемые значения станут аргументами функции вывода. (Аналогично selectCurrentUser, где сначала вызывается const currentUsername = selectCurrentUsername(state).)
Для выходного селектора нужны массив всех постов и ID пользователя. Используем существующий selectAllPosts для получения постов. Так как ID пользователя — второй аргумент selectPostsByUser, создадим селектор, возвращающий userId.
Функция вывода получает posts и userId, возвращая отфильтрованный массив постов этого пользователя.
Если мы попробуем вызывать selectPostsByUser несколько раз, она будет пересчитывать результат только при изменении posts или userId:
const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')
dispatch(fetchUsers())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')
// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')
Теперь, когда мы мемоизировали selectPostsByUser, повторим профилирование React в <UserPage> при получении уведомлений. На этот раз мы увидим, что <UserPage> не перерендеривается:

Балансировка использования селекторов
Мемоизированные селекторы — ценный инструмент для улучшения производительности в React+Redux приложениях, так как они помогают избежать ненужных перерендеров и дорогих вычислений при неизменных входных данных.
Важно: не все селекторы в приложении нужно мемоизировать! Большинство написанных нами селекторов остаются обычными функциями и работают нормально. Мемоизация нужна только селекторам, которые возвращают новые ссылки на объекты/массивы или выполняют "дорогие" вычисления.
Рассмотрим в качестве примера selectUnreadNotificationsCount:
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Этот селектор — обычная функция с вызовом .filter(). Однако обратите внимание: она не возвращает новый массив, а лишь число. Это безопаснее — даже при обновлении массива уведомлений возвращаемое значение не будет меняться постоянно.
Перефильтрация массива при каждом вызове всё же неоптимальна. Разумно преобразовать этот селектор в мемоизированный для экономии ресурсов процессора, но это не так критично, как если бы он возвращал новую ссылку каждый раз.
Подробнее о селекторах и мемоизации с Reselect:
Исследование списка постов
Если в <PostsList> нажать кнопку реакции при активном профилировании React, мы увидим перерендер не только <PostsList> и обновлённого <PostExcerpt>, но и всех компонентов <PostExcerpt>:

Почему так происходит? Другие посты не изменились — зачем их перерендеривать?
Поведение React по умолчанию: при рендере родительского компонента рекурсивно рендерятся все дочерние!. Неизменяемое обновление поста создаёт новый массив posts. <PostsList> перерендеривается из-за новой ссылки на массив posts, после чего React продолжает вниз по дереву, перерисовывая все <PostExcerpt>.
В нашем небольшом примере это не критично, но в реальных приложениях с длинными списками или большими деревьями компонентов лишние перерендеры могут замедлить работу.
Варианты оптимизации рендеринга списков
Есть несколько способов оптимизировать поведение <PostsList>:
Первый — обернуть <PostExcerpt> в React.memo(), что гарантирует перерендер только при изменении пропсов. Попробуйте этот подход:
let PostExcerpt = ({ post }: PostExcerptProps) => {
// omit logic
}
PostExcerpt = React.memo(PostExcerpt)
Другой вариант — переписать <PostsList> так, чтобы он извлекал из хранилища только список идентификаторов постов вместо всего массива posts, и изменить <PostExcerpt> для получения пропа postId с последующим вызовом useSelector для чтения нужного объекта поста. Если <PostsList> получит тот же список ID, перерендеринг не потребуется, и обновится только один изменённый компонент <PostExcerpt>.
К сожалению, это усложняется необходимостью сортировки постов по дате и правильной последовательности отрисовки. Мы могли бы обновить postsSlice для постоянной сортировки массива (чтобы избежать сортировки в компоненте) и использовать мемоизированный селектор для извлечения только списка ID постов. Альтернативно — настроить функцию сравнения в useSelector, например useSelector(selectPostIds, shallowEqual), что пропустит перерисовку при неизменном содержимом массива ID.
Последний вариант — организовать в редюсере отдельный массив ID всех постов, изменяемый только при добавлении/удалении постов, и аналогично переработать <PostsList> и <PostExcerpt>. Так <PostsList> будет перерисовываться только при изменении этого массива ID.
Удобно, что Redux Toolkit предоставляет функцию createEntityAdapter для решения именно этой задачи.
Нормализация данных
Вы заметили, что значительная часть нашей логики связана с поиском элементов по полю ID. Поскольку данные хранятся в массивах, приходится перебирать элементы через array.find(), пока не найдётся нужный ID.
Для небольших массивов это некритично, но при сотнях или тысячах элементов поиск по всему массиву становится неэффективным. Нужен способ прямого доступа к элементу по ID без проверки остальных. Этот процесс называется "нормализацией".
Структура нормализованного состояния
"Нормализованное состояние" подразумевает:
-
Отсутствие дублирования: каждый фрагмент данных представлен единственной копией
-
Хранение нормализованных данных в таблице поиска (обычный JS-объект), где ключи — ID элементов, а значения — сами элементы
-
Наличие массива всех ID для конкретного типа элементов
Объекты JavaScript могут выступать в роли таблиц поиска, аналогично "картам" или "словарям" в других языках. Пример нормализованного состояния для объектов user:
{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}
Это позволяет легко найти конкретный объект user по ID без перебора массива:
const userId = 'user2'
const userObject = state.users.entities[userId]
Подробнее о пользе нормализации: Нормализация структуры состояния и раздел Управление нормализованными данными в руководстве Redux Toolkit.
Управление нормализованным состоянием через createEntityAdapter
API createEntityAdapter из Redux Toolkit стандартизирует хранение данных в слайсе, преобразуя коллекцию элементов в структуру { ids: [], entities: {} }. С этой предопределённой формой состояния поставляются редюсеры и селекторы для работы с данными.
Преимущества:
-
Избавляет от необходимости самостоятельной реализации нормализации
-
Встроенные редюсеры
createEntityAdapterобрабатывают типичные сценарии: "добавить все элементы", "обновить один элемент" или "удалить несколько элементов" -
createEntityAdapterможет опционально поддерживать массив ID в отсортированном порядке на основе содержимого элементов, обновляя его только при добавлении/удалении элементов или изменении порядка сортировки.
createEntityAdapter принимает объект опций, который может включать функцию sortComparer для поддержания массива ID элементов в отсортированном порядке путём сравнения двух элементов (работает аналогично Array.sort()).
Метод возвращает объект, содержащий набор готовых функций-редюсеров для добавления, обновления и удаления элементов из состояния сущностей. Эти функции можно использовать как case-редюсеры для конкретных типов действий или как "мутирующие" утилиты внутри других редюсеров в createSlice.
Объект адаптера также содержит функцию getSelectors. Передав селектор, возвращающий конкретный срез состояния из корневого состояния Redux, вы получите сгенерированные селекторы типа selectAll и selectById.
Наконец, адаптер имеет функцию getInitialState, генерирующую пустой объект {ids: [], entities: {}}. Вы можете передать дополнительные поля в getInitialState, которые будут объединены с базовой структурой.
Нормализация среза постов
С учётом вышесказанного обновим наш postsSlice для использования createEntityAdapter. Потребуется внести несколько изменений.
Структура PostsState изменится: вместо массива posts: Post[] теперь будет использоваться {ids: string[], entities: Record<string, Post>}. Redux Toolkit уже предоставляет тип EntityState, описывающий структуру {ids, entities}, который мы импортируем как основу для PostsState. Также сохраним поля status и error.
Нам потребуется импортировать createEntityAdapter, создать экземпляр с типом Post и настроить сортировку постов.
import {
createEntityAdapter,
EntityState
// omit other imports
} from '@reduxjs/toolkit'
// omit thunks
interface PostsState extends EntityState<Post, string> {
status: 'idle' | 'pending' | 'succeeded' | 'rejected'
error: string | null
}
const postsAdapter = createEntityAdapter<Post>({
// Sort in descending date order
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const initialState: PostsState = postsAdapter.getInitialState({
status: 'idle',
error: null
})
// omit thunks
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
// omit other cases
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Save the fetched posts into state
postsAdapter.setAll(state, action.payload)
})
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
export default postsSlice.reducer
// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors((state: RootState) => state.posts)
export const selectPostsByUser = createSelector(
[selectAllPosts, (state: RootState, userId: string) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)
Рассмотрим процесс подробнее.
Сначала импортируем createEntityAdapter и создадим объект postsAdapter. Нам нужно сортировать ID постов так, чтобы новейшие шли первыми, поэтому передаём функцию sortComparer, сортирующую элементы по полю post.date.
getInitialState() возвращает пустой нормализованный объект состояния {ids: [], entities: {}}. Наш postsSlice также должен содержать поля status и error для отслеживания состояния загрузки, поэтому передаём их в getInitialState().
Теперь посты хранятся в таблице поиска state.entities, поэтому можем изменить редюсеры reactionAdded и postUpdated для прямой выборки постов по ID через state.entities[postId] вместо перебора старого массива posts.
При получении действия fetchPosts.fulfilled используем postsAdapter.setAll для добавления всех полученных постов в состояние, передав черновик state и массив постов из action.payload. Это пример использования методов адаптера как "мутирующих" хелперов внутри редюсера createSlice.
При получении addNewPost.fulfilled добавляем новый пост в состояние. Мы можем использовать функции адаптера как редюсеры напрямую, поэтому передаём postsAdapter.addOne в качестве редюсера для этого действия — метод адаптера выступает как сам редюсер.
Наконец, мы можем заменить написанные вручную функции-селекторы selectAllPosts и selectPostById на сгенерированные postsAdapter.getSelectors. Поскольку селекторы вызываются с корневым объектом состояния Redux, им необходимо знать, где в состоянии Redux находятся данные о постах. Поэтому мы передаём небольшой селектор, возвращающий state.posts. Сгенерированные функции-селекторы всегда называются selectAll и selectById, поэтому мы можем использовать синтаксис деструктуризации, чтобы переименовать их при экспорте и сохранить совместимость со старыми именами селекторов. Также аналогичным образом экспортируем selectPostIds, поскольку нам нужно читать отсортированный список ID постов в компоненте <PostsList>.
Мы можем ещё сократить пару строк, изменив postUpdated для использования метода postsAdapter.updateOne. Он принимает объект вида {id, changes}, где changes — это объект с полями для перезаписи:
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
postsAdapter.updateOne(state, { id, changes: { title, content } })
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
// omit `extraReducers`
})
Обратите внимание, что мы не можем использовать postsAdapter.updateOne с редьюсером reactionAdded, потому что логика сложнее. Вместо простой замены поля в объекте поста нам нужно увеличить счётчик внутри одного из вложенных полей. В этом случае допустимо найти объект и выполнить "мутабельное" обновление, как мы делали ранее.
Оптимизация списка постов
Теперь, когда наш срез постов использует createEntityAdapter, мы можем оптимизировать поведение рендеринга в <PostsList>.
Обновим <PostsList> так, чтобы он читал только отсортированный массив ID постов и передавал postId каждому компоненту <PostExcerpt>:
// omit other imports
import {
fetchPosts,
selectPostById,
selectPostIds,
selectPostsStatus,
selectPostsError
} from './postsSlice'
interface PostExcerptProps {
postId: string
}
function PostExcerpt({ postId }: PostExcerptProps) {
const post = useAppSelector(state => selectPostById(state, postId))
// omit rendering logic
}
export const PostsList = () => {
const dispatch = useAppDispatch()
const orderedPostIds = useAppSelector(selectPostIds)
// omit other selections and effects
if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}
// omit other rendering
}
Теперь, если нажать кнопку реакции на одном из постов во время записи профиля производительности React-компонентов, мы увидим, что перерендерился только этот компонент:

Нормализация среза пользователей
Мы можем преобразовать и другие срезы для использования createEntityAdapter.
Срез usersSlice довольно небольшой, поэтому изменения минимальны:
import {
createSlice,
createEntityAdapter
} from '@reduxjs/toolkit'
import { client } from '@/api/client'
import { createAppAsyncThunk } from '@/app/withTypes'
const usersAdapter = createEntityAdapter<User>()
const initialState = usersAdapter.getInitialState()
export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})
export default usersSlice.reducer
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors((state: RootState) => state.users)
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (!currentUsername) {
return
}
return selectUserById(state, currentUsername)
}
Единственное обрабатываемое действие всегда заменяет весь список пользователей массивом, полученным с сервера. Мы можем использовать usersAdapter.setAll для реализации этой логики.
Мы уже экспортировали написанные вручную селекторы selectAllUsers и selectUserById. Заменим их версиями, сгенерированными usersAdapter.getSelectors().
У нас действительно возникло небольшое несоответствие типов с selectUserById — согласно типам, наш currentUsername может быть null, но сгенерированный selectUserById не принимает такое значение. Простое решение — проверить, существует ли он, и завершить выполнение досрочно, если это не так.
Нормализация среза уведомлений
Наконец, обновим и notificationsSlice:
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
import { client } from '@/api/client'
// omit types and fetchNotifications thunk
const notificationsAdapter = createEntityAdapter<ClientNotification>({
// Sort with newest first
sortComparer: (a, b) => b.date.localeCompare(a.date)
})
const initialState = notificationsAdapter.getInitialState()
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))
Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
notificationsAdapter.upsertMany(state, notificationsWithMetadata)
})
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors((state: RootState) => state.notifications)
export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}
Снова импортируем createEntityAdapter, вызываем его и используем notificationsAdapter.getInitialState() для настройки среза.
Ирония в том, что у нас осталось несколько мест, где нужно перебрать все объекты уведомлений и обновить их. Поскольку они больше не хранятся в массиве, мы должны использовать Object.values(state.entities) для получения массива уведомлений и его перебора. С другой стороны, мы можем заменить предыдущую логику обновления при выборке на notificationsAdapter.upsertMany.
Написание реактивной логики
До сих пор всё поведение нашего приложения было относительно императивным. Пользователь выполняет действие (добавляет пост, запрашивает уведомления), а мы диспатчим действия либо в обработчике клика, либо в хуке useEffect компонента. Это относится и к thunk'ам выборки данных, таким как fetchPosts и login.
Однако иногда нам требуется писать дополнительную логику, которая выполняется в ответ на события в приложении, например диспетчеризацию определённых действий.
Мы уже показывали индикаторы загрузки, например, при получении постов. Было бы неплохо добавить визуальное подтверждение для пользователя при добавлении нового поста, например, всплывающее уведомление (toast message).
Мы уже видели, что несколько редьюсеров могут реагировать на одно и то же действие. Это отлично работает для логики, которая просто "обновляет несколько частей состояния", но что если нам нужно написать логику с асинхронными операциями или другими побочными эффектами? Мы не можем поместить это в редьюсеры — редьюсеры должны быть "чистыми" и не должны иметь побочных эффектов.
Если мы не можем поместить эту логику с побочными эффектами в редьюсеры, то где мы можем её разместить?
Ответ — в промежуточном ПО Redux (middleware), потому что оно предназначено для работы с побочными эффектами.
Реактивная логика с помощью createListenerMiddleware
Мы уже использовали middleware thunk для асинхронной логики, которая должна выполняться "прямо сейчас". Однако thunk — это просто функции. Нам нужен другой вид middleware, который позволит нам сказать: "когда диспетчится определенное действие, запусти эту дополнительную логику в ответ".
Redux Toolkit включает API createListenerMiddleware, позволяющее писать логику, которая выполняется в ответ на диспетчинг определённых действий. Оно позволяет добавлять записи "слушателей" (listeners), которые определяют, какие действия отслеживать, и имеют колбэк effect, который будет запускаться при совпадении действия.
Концептуально вы можете представить createListenerMiddleware как аналог React-хука useEffect, за исключением того, что он определяется как часть вашей Redux-логики (а не внутри React-компонента) и запускается в ответ на диспетчинг действий и обновления состояния Redux (а не как часть жизненного цикла рендеринга React).
Настройка Listener Middleware
Нам не приходилось специально настраивать middleware thunk, потому что configureStore из Redux Toolkit автоматически добавляет его при создании хранилища. Для listener middleware нам потребуется выполнить несколько шагов: создать его и добавить в хранилище.
Мы создадим новый файл app/listenerMiddleware.ts и создадим экземпляр listener middleware там. Подобно createAsyncThunk, мы передадим правильные типы для dispatch и state, чтобы безопасно обращаться к полям состояния и диспетчить действия.
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
export const listenerMiddleware = createListenerMiddleware()
export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening
export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener
Как и createSlice, createListenerMiddleware возвращает объект, содержащий несколько полей:
-
listenerMiddleware.middleware: непосредственно экземпляр Redux middleware, который нужно добавить в хранилище -
listenerMiddleware.startListening: добавляет новую запись слушателя (listener) непосредственно в middleware -
listenerMiddleware.addListener: создатель действия (action creator), который можно диспетчить для добавления записи слушателя из любой части кодовой базы, имеющей доступ кdispatch, даже без импорта объектаlistenerMiddleware
Как и с асинхронными thunk и хуками, мы можем использовать методы .withTypes() для определения предварительно типизированных функций startAppListening и addAppListener со встроенными правильными типами.
Затем нам нужно добавить его в хранилище:
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
import notificationsReducer from '@/features/notifications/notificationsSlice'
import { listenerMiddleware } from './listenerMiddleware'
export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})
configureStore уже по умолчанию добавляет middleware redux-thunk при создании хранилища, а также дополнительное middleware в development-режиме для проверок безопасности. Мы хотим сохранить их, но также добавить и listener middleware.
Порядок настройки middleware может иметь значение, потому что они образуют конвейер: m1 -> m2 -> m3 -> store.dispatch(). В данном случае listener middleware должен быть в начале конвейера, чтобы он мог первым перехватывать определённые действия и обрабатывать их.
Функция getDefaultMiddleware() возвращает массив настроенных middleware. Будучи массивом, она уже имеет метод .concat(), который возвращает копию с новыми элементами в конце массива. Однако configureStore также добавляет эквивалентный метод .prepend(), создающий копию с новыми элементами в начале массива.
Поэтому мы вызовем getDefaultMiddleware().prepend(listenerMiddleware.middleware), чтобы добавить middleware в начало списка.
Отображение уведомлений о новых постах
Теперь, когда middleware для обработки событий настроено, мы можем добавить новый обработчик, который будет показывать уведомление при успешном добавлении нового поста.
Мы будем использовать библиотеку react-tiny-toast для отображения стильных уведомлений. Она уже включена в проект, поэтому устанавливать её не требуется.
Нам нужно импортировать и отрендерить компонент <ToastContainer> в нашем <App>:
import React from 'react'
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { ToastContainer } from 'react-tiny-toast'
// omit other imports and ProtectedRoute definition
function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>{/* omit routes content */}</Routes>
<ToastContainer />
</div>
</Router>
)
}
Теперь добавим обработчик, который будет отслеживать действие addNewPost.fulfilled, показывать уведомление "Пост добавлен" и скрывать его через заданное время.
Существует несколько подходов к организации обработчиков в кодовой базе. Обычно рекомендуется размещать обработчики в том файле слайса, который логически связан с добавляемой функциональностью. В данном случае мы хотим показывать уведомление при добавлении поста, поэтому добавим этот обработчик в файл postsSlice:
import {
createEntityAdapter,
createSelector,
createSlice,
EntityState,
PayloadAction
} from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { AppStartListening } from '@/app/listenerMiddleware'
import { createAppAsyncThunk } from '@/app/withTypes'
// omit types, initial state, slice definition, and selectors
export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: addNewPost.fulfilled,
effect: async (action, listenerApi) => {
const { toast } = await import('react-tiny-toast')
const toastId = toast.show('New post added!', {
variant: 'success',
position: 'bottom-right',
pause: true
})
await listenerApi.delay(5000)
toast.remove(toastId)
}
})
}
Для добавления обработчика нужно вызвать функцию startAppListening, определённую в app/listenerMiddleware.ts. Однако лучше не импортировать startAppListening напрямую в файл слайса, чтобы сохранить согласованность цепочек импорта. Вместо этого можно экспортировать функцию, принимающую startAppListening как аргумент. Таким образом, файл app/listenerMiddleware.ts сможет импортировать эту функцию, аналогично тому, как app/store.ts импортирует редьюсеры слайсов из их файлов.
Чтобы добавить обработчик, вызовите startAppListening и передайте объект с callback-функцией effect и одним из параметров для определения условий её запуска:
-
actionCreator: ActionCreator: любой RTK action creator, напримерreactionAddedилиaddNewPost.fulfilled. Запускает эффект при диспатче конкретного действия. -
matcher: (action: UnknownAction) => boolean: любая функция-"матчер" RTK, напримерisAnyOf(reactionAdded, addNewPost.fulfilled). Запускает эффект, когда функция-матчер возвращаетtrue. -
predicate: (action: UnknownAction, currState: RootState, prevState: RootState) => boolean: универсальная функция для любых проверок, которая имеет доступ кcurrStateиprevState. Она позволяет выполнять любые проверки действия или состояния, включая проверку изменений (напримерcurrState.counter.value !== prevState.counter.value).
В нашем случае мы хотим показывать уведомление при успешном выполнении addNewPost, поэтому укажем actionCreator: addNewPost.fulfilled.
Callback-функция effect похожа на асинхронный thunk. Она получает соответствующее action первым аргументом и объект listenerApi — вторым.
listenerApi включает стандартные dispatch и getState, а также другие функции для реализации сложной асинхронной логики, например:
condition()для ожидания другого действия или изменения состоянияunsubscribe()/subscribe()для управления активностью обработчикаfork()для запуска дочерней задачи
В данном случае нам нужно динамически импортировать библиотеку react-tiny-toast, показать уведомление об успехе, подождать несколько секунд, а затем скрыть уведомление.
Наконец, нам нужно импортировать и вызвать addPostsListeners в нужном месте. В нашем случае мы импортируем его в app/listenerMiddleware.ts:
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'
import { addPostsListeners } from '@/features/posts/postsSlice'
export const listenerMiddleware = createListenerMiddleware()
export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening
export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener
// Call this and pass in `startAppListening` to let the
// posts slice set up its listeners
addPostsListeners(startAppListening)
Теперь при добавлении нового поста в правом нижнем углу страницы должно появляться маленькое зелёное уведомление, которое исчезает через 5 секунд. Это работает потому, что прослойка для прослушивания (listener middleware) в хранилище Redux проверяет и запускает колбэк эффекта после диспетчеризации действия, даже если мы не добавляли дополнительную логику в сами React-компоненты.
Итоги изученного
Мы добавили много новой функциональности в этом разделе. Давайте посмотрим, как теперь выглядит приложение со всеми этими изменениями:
Вот что мы рассмотрели в этом разделе:
- Мемоизированные функции-селекторы оптимизируют производительность
- Redux Toolkit реэкспортирует функцию
createSelectorиз Reselect для создания мемоизированных селекторов - Мемоизированные селекторы пересчитывают результаты только при изменении входных значений
- Мемоизация позволяет пропускать ресурсоёмкие вычисления и гарантирует возврат одинаковых ссылок на результаты
- Redux Toolkit реэкспортирует функцию
- Существует несколько подходов к оптимизации рендеринга React-компонентов с Redux
- Избегайте создания новых ссылок на объекты/массивы внутри
useSelector— это вызывает лишние перерисовки - В
useSelectorможно передавать мемоизированные функции-селекторы для оптимизации рендеринга useSelectorможет использовать альтернативные функции сравнения, напримерshallowEqual, вместо проверки по ссылке- Компоненты можно обернуть в
React.memo(), чтобы они перерисовывались только при изменении пропсов - Рендеринг списков можно оптимизировать: родительский компонент получает массив ID элементов, передаёт ID дочерним компонентам, которые получают элементы по ID
- Избегайте создания новых ссылок на объекты/массивы внутри
- Нормализованная структура состояния рекомендована для хранения данных
- "Нормализация" означает отсутствие дублирования данных и хранение элементов в таблице поиска по ID
- Нормализованное состояние обычно имеет вид
{ids: [], entities: {}}
- API
createEntityAdapterиз Redux Toolkit помогает управлять нормализованными данными в слайсе- ID элементов можно хранить в отсортированном порядке через опцию
sortComparer - Объект адаптера включает:
adapter.getInitialState, который принимает дополнительные поля состояния (например, статус загрузки)- Предопределённые редьюсеры для типовых сценариев:
setAll,addMany,upsertOne,removeMany adapter.getSelectors, генерирующий селекторы типаselectAllиselectById
- ID элементов можно хранить в отсортированном порядке через опцию
- API
createListenerMiddlewareиз Redux Toolkit запускает реактивную логику в ответ на действия- Прослойку для прослушивания нужно добавить при настройке хранилища с правильными типами
- Слушатели обычно определяются в файлах слайсов, но могут структурироваться иначе
- Слушатели могут реагировать на одно действие, несколько действий или использовать кастомные проверки
- Коллбэки эффектов могут содержать любую синхронную или асинхронную логику
- Объект
listenerApiпредоставляет методы для управления асинхронными процессами
Что дальше?
Redux Toolkit также включает мощное API для получения и кэширования данных под названием "RTK Query". RTK Query — это опциональное дополнение, которое полностью избавляет от необходимости писать логику получения данных самостоятельно. В Части 7: Основы RTK Query вы узнаете, что такое RTK Query, какие задачи оно решает и как использовать его для получения и работы с кэшированными данными в приложении.