Приветствую на восьмом уроке нашего большого курса по Redux и Redux Toolkit. Сегодня мы разберем две практичные темы, которые сделают наш код чище, эффективнее и удобнее в поддержке.
Мы уже научились создавать слайсы, диспатчить экшены и читать состояние в компонентах. Но по мере роста приложения, простой вызов useSelector(state => state.todos.list) может привести к проблемам. Представь, что структура состояния изменилась. Придется бегать по всем компонентам и вносить правки. А еще бывают сложные вычисления на основе состояния, которые не хочется делать прямо в компоненте. И тут нам на помощь приходят Селекторы.
Кроме того, когда мы создаем экшен для добавления новой задачи, нам часто нужно сгенерировать для нее уникальный ID. Делать это в компоненте перед диспатчем не лучшая идея, потому что логика создания сущности «задача» должна быть инкапсулирована в слайсе. И здесь нас выручит вторая тема урока, это подготовка payload с помощью колбэка prepare.
Давай же поймем эти концепции и сделаем наш код по-настоящему профессиональным.
Что такое Селекторы?
Селектор это простая функция, которая принимает все состояние Redux-стора (или его часть) и возвращает какие-то конкретные данные из него. Можно думать о селекторе как о «геттере» для нашего состояния. Он знает его точную структуру и умеет извлекать нужные «кусочки».
Давай вспомним, как мы обычно получаем данные в компоненте:
const todoList = useSelector(state => state.todos.list);
Это, по сути, уже простейший инлайн-селектор. Он написан прямо в компоненте. Но что будет, если мы в слайсе todos переименуем свойство list в items? Нам придется найти и исправить эту строку во всех компонентах, где мы читаем список задач. Это утомительно и чревато ошибками.
Вот главные причины выносить селекторы в отдельные функции:
-
Переиспользование. Если один и тот же кусок состояния нужен в десяти компонентах, мы просто импортируем один и тот же селектор, а не копируем логику десять раз.
-
Инкапсуляция. Селектор знает структуру состояния. Если эта структура меняется, нам нужно поправить код только в одном месте – в файле с селекторами, а не в десятках компонентов.
-
Производительность (мемоизация). Селекторы могут быть «мемоизированы». Это значит, что они будут пересчитывать результат только тогда, когда изменились те части состояния, от которых они зависят. Это предотвращает лишние перерисовки компонентов.
Давай начнем с создания простых, немемоизированных селекторов для нашего слайса задач.
Создаем базовые селекторы
Предположим, у нас есть слайс todos с таким начальным состоянием:
const initialState = { items: [ { id: 1, text: 'Изучить React', completed: true }, { id: 2, text: 'Изучить Redux', completed: false }, { id: 3, text: 'Создать приложение', completed: false } ], filter: 'ALL' // 'ALL', 'COMPLETED', 'ACTIVE' };
Мы можем создать селекторы в том же файле, где у нас слайс или в отдельном файле, например, todosSelectors.js.
// В файле todosSlice.js // Сам слайс (напоминание) import { createSlice } from '@reduxjs/toolkit'; const todosSlice = createSlice({ name: 'todos', initialState, reducers: { // ... наши редьюсеры } }); // Простые селекторы // Селектор, который возвращает весь массив задач export const selectAllTodos = (state) => state.todos.items; // Селектор, который возвращает значение фильтра export const selectTodosFilter = (state) => state.todos.filter; // Селектор, который возвращает только выполненные задачи export const selectCompletedTodos = (state) => { return state.todos.items.filter(todo => todo.completed); }; // Селектор, который возвращает только активные задачи export const selectActiveTodos = (state) => { return state.todos.items.filter(todo => !todo.completed); }; // Экспортируем редьюсер и экшены как обычно export const { /* actions */ } = todosSlice; export default todosSlice.reducer;
Теперь в нашем компоненте мы будем использовать эти селекторы:
import React from 'react'; import { useSelector } from 'react-redux'; import { selectAllTodos, selectTodosFilter } from './todosSlice'; const TodoList = () => { // Используем готовые селекторы const todos = useSelector(selectAllTodos); const filter = useSelector(selectTodosFilter); // Логику фильтрации можно тоже вынести в селектор! const filteredTodos = todos.filter(todo => { if (filter === 'COMPLETED') return todo.completed; if (filter === 'ACTIVE') return !todo.completed; return true; }); return ( <ul> {filteredTodos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> ); }; export default TodoList;
Компонент теперь не знает о точной структуре состояния state.todos.items. Он просто использует функцию selectAllTodos, которая берет на себя ответственность за то, как достать задачи. Если завтра мы решим хранить задачи в state.todos.data.list, нам нужно будет изменить только селектор и все компоненты продолжат работать.
Мемоизация селекторов с помощью createSelector
Ты мог заметить, что в предыдущем примере мы фильтруем задачи прямо в компоненте. Это не самое страшное преступление, но давай представим более тяжелый сценарий: у нас тысячи задач и нам нужно выполнять сложные вычисления (например поиск, сортировку, агрегацию).
Если такой сложный расчет происходит в компоненте, он будет выполняться при каждом его рендере, даже если состояние todos не изменилось! Это может серьезно ударить по производительности.
Здесь на сцену выходит createSelector из Redux Toolkit. Это функция для создания мемоизированных селекторов. Она запоминает («кэширует») результат своего вычисления. Если входные аргументы (части состояния, от которых зависит селектор) не изменились, селектор возвращает закэшированный результат, не производя перерасчет.
createSelector это так называемый «селектор с зависимостями» (reselect selector). Он принимает один или несколько «входных» селекторов и функцию-трансформер.
Синтаксис:
const memoizedSelector = createSelector([inputSelector1, inputSelector2, ...], resultFunc)
-
inputSelector1,inputSelector2это селекторы, результаты которых будут переданы вresultFunc. -
resultFuncэто функция, которая принимает результаты входных селекторов и возвращает конечное, производное значение. Мемоизация работает именно для этой функции.
Давай улучшим наш пример. Создадим мемоизированный селектор для отфильтрованных задач.
// todosSlice.js import { createSlice, createSelector } from '@reduxjs/toolkit'; // ... (наш слайс и initialState) // Базовые селекторы (input selectors) // Они очень простые, их мемоизировать не нужно. const selectTodosItems = (state) => state.todos.items; const selectTodosFilter = (state) => state.todos.filter; // Создаем мемоизированный селектор. // Он зависит от selectTodosItems и selectTodosFilter. // Если items и filter не изменились, повторного выполнения filter не произойдет. export const selectFilteredTodos = createSelector( [selectTodosItems, selectTodosFilter], // входные селекторы (items, filter) => { // функция-трансформер console.log('Выполняется сложный расчет filteredTodos!'); // Для демонстрации switch (filter) { case 'COMPLETED': return items.filter(todo => todo.completed); case 'ACTIVE': return items.filter(todo => !todo.completed); default: return items; } } ); // Другой пример: селектор для статистики export const selectTodosStats = createSelector( [selectTodosItems], (items) => { const total = items.length; const completed = items.filter(todo => todo.completed).length; const active = total - completed; return { total, completed, active }; } );
Теперь используем эти мощные селекторы в нашем компоненте:
import React from 'react'; import { useSelector } from 'react-redux'; import { selectFilteredTodos, selectTodosStats } from './todosSlice'; const TodoList = () => { // Этот селектор мемоизирован. Перерасчет будет только при смене items или filter. const filteredTodos = useSelector(selectFilteredTodos); // И этот тоже! Статистика пересчитается только при изменении items. const stats = useSelector(selectTodosStats); return ( <div> <div>Всего: {stats.total}, Выполнено: {stats.completed}, Активных: {stats.active}</div> <ul> {filteredTodos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> </div> ); }; export default TodoList;
Попробуй добавить console.log в функцию-трансформер внутри createSelector. Ты увидишь, что сообщение выводится в консоль только тогда, когда действительно меняется items или filter. Если диспатчить экшен, не связанный с задачами (например инкремент счетчика), этот селектор не будет пересчитываться и компонент не будет выполнять лишней работы. Это огромный выигрыш в производительности для сложных приложений!
Подготовка Payload с помощью prepare
Теперь давай переключимся на вторую тему, это создание экшенов. Часто payload (полезная нагрузка) экшена не приходит в готовом виде из компонента. Например, при добавлении новой задачи нам нужно:
-
Сгенерировать для нее уникальный
id. -
Добавить поле
completed: falseпо умолчанию. -
Добавить временную метку
createdAt.
Мы могли бы делать это прямо в компоненте перед вызовом dispatch(addTodo({ id: Date.now(), text: inputText, completed: false })). Но это плохая практика! Логика создания новой задачи «размазывается» по всему приложению. Если нам понадобится добавлять задачу из другого места, придется дублировать этот код.
Гораздо лучше инкапсулировать эту логику прямо в слайсе. И для этого в Redux Toolkit есть специальное свойство prepare.
prepare это функция, которую можно передать вместе с редьюсером в createSlice. Она используется для подготовки и форматирования payload экшена перед тем, как он попадет в редьюсер.
Синтаксис:
reducers: { actionName: { reducer: (state, action) => { // Здесь action.payload уже подготовлен функцией prepare }, prepare: (...args) => { // Здесь мы получаем исходные аргументы // и возвращаем объект с полем `payload` // (и, опционально, `meta` и `error`) return { payload: /* сформированный payload */ } } } }
Давай модернизируем наш слайс задач. Добавим редьюсер addTodo с подготовкой payload.
Практический пример: Добавляем prepare для генерации ID
// todosSlice.js import { createSlice, nanoid } from '@reduxjs/toolkit'; // nanoid - удобная утилита для генерации ID const todosSlice = createSlice({ name: 'todos', initialState, reducers: { // Старая, простая версия: // addTodo: (state, action) => { // state.items.push(action.payload); // }, // Новая версия с prepare! addTodo: { // Редьюсер теперь ожидает, что в payload придет готовый объект задачи. reducer: (state, action) => { state.items.push(action.payload); }, // Prepare получает то, что было передано в диспатч из компонента. // Например, dispatch(addTodo('Купить молоко')). // Здесь мы готовим итоговый payload. prepare: (text) => { return { payload: { id: nanoid(), // Генерируем уникальный ID text: text, // Используем переданный текст completed: false, // Устанавливаем по умолчанию createdAt: new Date().toISOString(), // Добавляем временную метку } }; } }, // ... другие редьюсеры (toggleTodo, removeTodo и т.д.) } }); // Обрати внимание: экшен `addTodo` экспортируется и используется как обычно! export const { addTodo, toggleTodo } = todosSlice.actions; export default todosSlice.reducer;
Теперь посмотрим, как изменилось использование в компоненте:
import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { addTodo } from './todosSlice'; const AddTodoForm = () => { const [inputText, setInputText] = useState(''); const dispatch = useDispatch(); const handleSubmit = (e) => { e.preventDefault(); if (inputText.trim()) { // Мы диспатчим экшен, передавая ТОЛЬКО текст. // Вся логика создания объекта задачи (id, completed, createdAt) // инкапсулирована в слайсе, в функции prepare! dispatch(addTodo(inputText)); setInputText(''); } }; return ( <form onSubmit={handleSubmit}> <input type="text" value={inputText} onChange={(e) => setInputText(e.target.value)} /> <button type="submit">Добавить задачу</button> </form> ); }; export default AddTodoForm;
Видишь, насколько это правильнее? Компонент теперь не знает о внутреннем устройстве задачи. Он просто говорит: «Хочу добавить задачу с таким-то текстом». А всю грязную работу по приданию ей нужной формы берет на себя слайс. Это классический пример инкапсуляции и соблюдения принципа единственной ответственности.
Итоговый код и практическое задание
Давай соберем все вместе в нашем финальном todosSlice.js:
import { createSlice, createSelector, nanoid } from '@reduxjs/toolkit'; const initialState = { items: [], filter: 'ALL' }; const todosSlice = createSlice({ name: 'todos', initialState, reducers: { addTodo: { reducer: (state, action) => { state.items.push(action.payload); }, prepare: (text) => { return { payload: { id: nanoid(), text, completed: false, createdAt: new Date().toISOString(), } }; } }, toggleTodo: (state, action) => { const todo = state.items.find(todo => todo.id === action.payload); if (todo) { todo.completed = !todo.completed; } }, removeTodo: (state, action) => { state.items = state.items.filter(todo => todo.id !== action.payload); }, setFilter: (state, action) => { state.filter = action.payload; } } }); // Базовые селекторы const selectTodosItems = (state) => state.todos.items; const selectTodosFilter = (state) => state.todos.filter; // Мемоизированные селекторы export const selectFilteredTodos = createSelector( [selectTodosItems, selectTodosFilter], (items, filter) => { switch (filter) { case 'COMPLETED': return items.filter(todo => todo.completed); case 'ACTIVE': return items.filter(todo => !todo.completed); default: return items; } } ); export const selectTodosStats = createSelector( [selectTodosItems], (items) => { const total = items.length; const completed = items.filter(todo => todo.completed).length; const active = total - completed; return { total, completed, active }; } ); export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions; export default todosSlice.reducer;
Практическое задание для закрепления
-
Базовый уровень. Возьми свой текущий проект со счетчиком и задачами. Создай для слайса задач базовые селекторы
selectAllTodosиselectTodosFilter. Используй их в своих компонентах вместо инлайн-селекторов. -
Продвинутый уровень. Добавь в слайс задач редьюсер
addTodoс использованиемprepare, как показано в примере выше. Убедись, что компонент формы добавления теперь передает только строку с текстом. -
Уровень эксперт. Реализуй мемоизированный селектор
selectFilteredTodos. Создай также селекторselectTodosStats, который возвращает объект с статистикой{ total, completed, active }. Выведи эту статистику в интерфейсе над списком задач. Для проверки мемоизации добавьconsole.logв функцию-трансформерselectFilteredTodosи убедись, что при диспатче несвязанных экшенов (например инкремента счетчика) перерасчета не происходит.
Селекторы и prepare это признаки качественного, масштабируемого кода. Они делают приложение более быстрым, поддерживаемым и логически организованным.
В следующем уроке мы столкнемся с одной из самых интересных тем, это асинхронными операциями. Мы научимся делать запросы к API с помощью createAsyncThunk. Это откроет перед нами двери в мир реальных полноценных приложений.
Полный курс с уроками по Redux и Redux Toolkit для начинающих доступен по ссылке:
https://max-gabov.ru/redux-dlya-nachinaushih
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


