Мемоізація та Селектори: Повний Гайд по Reselect
Мемоізація та Селектори: Повний Гайд по Reselect
У світі Redux селектори часто недооцінюють. Багато розробників сприймають їх просто як функції для отримання шматочка даних зі стейту. Але насправді, селектори — це потужний шар абстракції, який відповідає за ефективність, інкамисуляцію та обчислення похідних даних.
Коли ваш додаток розростається (особливо це стосується Enterprise-проєктів з тисячами сутностей), бездумна вибірка даних призводить до катастрофічних втрат продуктивності. Компоненти ре-рендеряться без потреби, обчислення повторюються тисячі разів на секунду, а інтерфейс починає "гальмувати" при кожному натисканні клавіші.
Цей гайд повністю розкриє тему селекторів: від базової філософії до складних патернів мемоізації з Reselect, типізації в TypeScript та архітектурних рішень.
Частина 1: Філософія Селекторів
Що таке селектор?
Формально, селектор — це будь-яка чиста функція, яка приймає весь стейт Redux (або його частину) і повертає значення.
// Найпростіший селектор
const selectUser = (state) => state.user
// Селектор з обчисленням
const selectUserAge = (state) => {
const birthDate = new Date(state.user.birthDate)
const today = new Date()
return today.getFullYear() - birthDate.getFullYear()
}
Але концептуально, селектори — це Query Layer (Шар Запитів) для вашого стейту.
Чому це важливо?
- Інкапсуляція: Компоненти не повинні знати про структуру стейту. Якщо ви зміните структуру
state.userнаstate.auth.user, вам потрібно буде оновити лише селектор, а не 50 компонентів. - Похідні дані (Derived Data): Стейт повинен зберігати мінімально необхідні дані. Все інше (відфільтровані списки, суми, форматовані дати) має обчислюватися "на льоту" в селекторах.
- Продуктивність: Правильні селектори запобігають зайвим ре-рендерам.
Принцип "Derived Data"
Одне з найважливіших правил Redux: зберігайте в стейті мінімум даних. Все інше обчислюйте.
Приклад: У нас є список товарів і фільтр.
- ❌ Погано: Зберігати в стейті
itemsі окремоfilteredItems. - ✅ Добре: Зберігати тільки
itemsіfilter.filteredItemsобчислювати селектором.
Чому? Тому що якщо ви додасте новий товар, вам доведеться оновлювати і items, і filteredItems. Це порушення Single Source of Truth.
Частина 2: Проблема Референційної Цілісності (Referential Integrity)
Це "серце" проблеми продуктивності в React + Redux. Якщо ви не зрозумієте цей розділ, ви не зрозумієте Reselect.
Як працює useSelector?
Хук useSelector підписується на оновлення Redux Store. Щоразу, коли будь-яка екшен викликається і стейт оновлюється, useSelector:
- Запускає вашу функцію-селектор.
- Порівнює результат з попереднім результатом.
- Якщо результат змінився (нове посилання), він змушує компонент ре-рендеритися.
- Якщо результат той самий (те саме посилання), нічого не відбувається.
Порівняння відбувається через сувору рівність (===).
Пастка нових посилань
Розглянемо найпоширенішу помилку:
// ❌ ПОГАНО: Завжди повертає нове посилання
const selectActiveTodos = (state) => {
// .filter() ЗАВЖДИ створює новий масив, навіть якщо вміст не змінився!
return state.todos.filter((todo) => !todo.completed)
}
Що відбувається під капотом:
- Екшен:
dispatch({ type: 'UI/THEME_CHANGED' }). - Redux стейт оновлюється (змінилася тема, але
todosті самі). useSelectorзапускаєselectActiveTodos.state.todos.filter(...)запускається і створює новий масивArray(5).- Попередній результат був
Array(5). - Перевірка:
newArray === oldArray-> false. - React Re-render! 😱
Ми змінили тему, а всі компоненти списку завдань перемалювалися. Це і називається "wasted renders".
Приклади порушення Referenital Integrity
| Операція | Чому це погано в селекторі? |
|---|---|
.map(...) | Створює новий масив. |
.filter(...) | Створює новий масив. |
[...array] | Створює новий масив. |
{ ...object } | Створює новий об'єкт. |
obj || {} | Якщо obj falsy, створює новий об'єкт {}. |
Date.parse(...) | Числа примітивні, це ОК. Але new Date() - це новий об'єкт. |
Демонстрація проблеми
const state = {
items: [1, 2, 3],
}
// Виклик 1
const result1 = state.items.map((x) => x * 2)
// result1 = [2, 4, 6]
// Виклик 2 (стейт не змінився!)
const result2 = state.items.map((x) => x * 2)
// result2 = [2, 4, 6]
// Перевірка
console.log(result1 === result2) // FALSE ❌
Для React це означає: "Дані змінилися, треба малювати заново".
Частина 3: Reselect та Мемоізація
Мемоізація — це техніка оптимізації, яка кешує результат виконання функції на основі її аргументів. Якщо аргументи не змінилися, функція повертає закешований результат, не виконуючи обчислень.
Бібліотека Reselect (яка тепер вбудована в Redux Toolkit) надає функцію createSelector для створення мемоізованих селекторів.
Анатомія createSelector
import { createSelector } from '@reduxjs/toolkit'
const selectMemoizedData = createSelector(
// [A] Input Selectors (Залежності)
[inputSelector1, inputSelector2],
// [B] Result Function (Трансформатор)
(result1, result2) => {
// [C] Важкі обчислення
return doExpensiveCalculation(result1, result2)
},
)
Алгоритм роботи:
- Виклик Input Selectors: Коли ви викликаєте
selectMemoizedData(state), Reselect спочатку запускає всі функції з масиву[A]. - Перевірка змін: Він порівнює результати цих функцій з попередніми результатами (saved arguments).
- Кеш-хіт (Cache Hit): Якщо результати input selectors ідентичні (
===) попереднім, Reselect не запускає Result Function[B]і одразу повертає збережений результат. - Кеш-міс (Cache Miss): Якщо хоча б один результат змінився, Reselect запускає Result Function
[B], зберігає новий результат і повертає його.
Виправляємо наш приклад
const selectTodos = (state) => state.todos
// ✅ ДОБРЕ: Мемоізований селектор
const selectActiveTodos = createSelector([selectTodos], (todos) => {
console.log('Фільтрація...') // Виконається тільки якщо todos зміняться
return todos.filter((todo) => !todo.completed)
})
Тепер, якщо ми змінимо тему (UI/THEME_CHANGED):
selectTodosповерне той самий масивtodos(бо він не змінився).createSelectorпобачить, що аргумент не змінився.- Він поверне той самий масив (посилання), що й минулого разу.
useSelectorпобачить, що посилання те саме -> Re-render скасовано. 🎉
Частина 4: Каскадна Мемоізація (Composition)
Справжня сила селекторів розкривається в їх компонуванні. Ви можете будувати селектори на базі інших селекторів.
Піраміда даних
Уявіть собі піраміду:
- Рівень 0: "Сирі" дані (Raw State).
- Рівень 1: Прості виборки (Simple Lookups).
- Рівень 2: Трансформації (фільтрація, сортування).
- Рівень 3: Агрегації (статистика, звіти).
- Рівень 4: View Models (дані, готові для UI).
// --- Рівень 0/1: Input Selectors ---
const selectAllTodos = (state) => state.todos.items
const selectFilter = (state) => state.todos.filter
const selectSearchQuery = (state) => state.todos.search
// --- Рівень 2: Фільтрація ---
const selectFilteredTodos = createSelector(
[selectAllTodos, selectFilter, selectSearchQuery],
(todos, filter, query) => {
// Важка логіка фільтрації
let result = todos
if (filter === 'completed') {
result = result.filter((t) => t.completed)
}
if (query) {
result = result.filter((t) => t.title.includes(query))
}
return result
},
)
// --- Рівень 3: Статистика ---
const selectTodoStats = createSelector(
[selectFilteredTodos], // Залежить від вже фільтрованих!
(todos) => {
return {
count: todos.length,
urgent: todos.filter((t) => t.priority === 'high').length,
}
},
)
// --- Рівень 4: View Model ---
const selectTodoListViewModel = createSelector([selectFilteredTodos, selectTodoStats], (todos, stats) => ({
items: todos,
subtitle: `Shown ${stats.count} items (${stats.urgent} urgent)`,
}))
Як це працює при оновленні?
Припустимо, користувач змінив searchQuery.
selectSearchQuery-> Нове значення.selectFilteredTodos-> Input змінився -> Перераховує фільтрацію -> Новий масивtodos.selectTodoStats-> Input змінився -> Перераховує статистику.selectTodoListViewModel-> Inputs змінилися -> Оновлює View Model.- React UI оновлюється.
А тепер припустимо, користувач змінив своє ім'я в профілі (user.name).
selectSearchQuery-> Те саме.selectAllTodos-> Те саме.selectFilter-> Те саме.selectFilteredTodos-> Inputs не змінилися -> Cache Hit. Повертає старий масив.selectTodoStats-> Input (результат попереднього) не змінився -> Cache Hit.selectTodoListViewModel-> Inputs не змінилися -> Cache Hit.- React UI НЕ оновлюється. Економія ресурсів колосальна!
Частина 5: Конфігурація Мемоізації
Reselect надає потужні можливості для налаштування того, як саме працює мемоізація.
lruMemoize vs weakMapMemoize
За замовчуванням createSelector використовує стратегію LRU (Least Recently Used) з розміром кешу 1.
lruMemoize
Це стандартна функція мемоізації. Вона зберігає останні N викликів.
import { createSelector, lruMemoize } from '@reduxjs/toolkit'
const selectItems = createSelector([input], resultFunc, {
memoize: lruMemoize,
memoizeOptions: {
equalityCheck: (a, b) => a === b, // Стандартна перевірка
maxSize: 1, // Розмір кешу
resultEqualityCheck: (a, b) => a === b, // Перевірка результату
},
})
Коли змінювати maxSize?
Якщо один і той же селектор використовується в різних місцях з різними аргументами (але не одночасно), збільшення maxSize може допомогти уникнути перерахунків при перемиканні контексту.
weakMapMemoize
Нова опція в Reselect 5.0. Вона використовує WeakMap для зберігання результатів.
Особливості:
- Тільки для об'єктів: Аргументи повинні бути об'єктами (не примітивами).
- Нескінченний кеш: Поки об'єкт-ключ існує в пам'яті, результат зберігається.
- Автоматичне очищення: Коли об'єкт-ключ видаляється Garbage Collector, запис у кеші теж зникає.
Це ідеально підходить, коли аргументом селектора є сам стейт або великий об'єкт пропсів.
import { createSelector, weakMapMemoize } from '@reduxjs/toolkit'
const selectData = createSelector([(state) => state.someObject], (obj) => complexCalculation(obj), {
memoize: weakMapMemoize, // Використовуємо WeakMap
argsMemoize: weakMapMemoize, // Для аргументів теж
})
Кастомна перевірка рівності (equalityCheck)
Іноді === недостатньо. Наприклад, якщо input selector повертає новий масив, але його вміст ідентичний.
import { createSelector } from '@reduxjs/toolkit'
import { isEqual } from 'lodash'
const selectDeep = createSelector([(state) => state.deeplyNestedObject], (obj) => calculate(obj), {
memoizeOptions: {
// ⚠️ Use with caution! Deep comparison is slow!
equalityCheck: isEqual,
},
})
Застереження: Глибоке порівняння (deep equality) може бути повільним саме по собі. Іноді швидше просто перерахувати селектор, ніж порівнювати два величезних дерева об'єктів.
Частина 6: Селектори з пропсами (Arguments)
Іноді селектору потрібні дані не тільки зі стейту, але й з параметрів компонента (наприклад, id продукту).
const selectProductById = (state, productId) => state.products.entities[productId]
Це працює, але як це мемоізувати? createSelector дозволяє передавати додаткові аргументи. Аргументи з useSelector передаються в Input Selectors як другий, третій і т.д. аргументи.
// Input Selectors
const selectItems = (state) => state.items
const selectItemId = (state, itemId) => itemId // Просто повертаємо ID
const selectItemById = createSelector(
[selectItems, selectItemId], // Передаємо обидва
(items, itemId) => {
console.log(`Searching for item ${itemId}`)
return items.find((item) => item.id === itemId)
},
)
Використання в компоненті:
function ItemComponent({ id }) {
// Передаємо state ТА id
const item = useSelector((state) => selectItemById(state, id))
return <div>{item.name}</div>
}
🚨 Критична проблема: Shared Cache
За замовчуванням createSelector має розмір кешу = 1. Це означає, що він пам'ятає лише останній набір аргументів.
Якщо ви використовуєте цей селектор у списку компонентів:
function ItemList() {
return (
<>
<ItemComponent id={1} />
<ItemComponent id={2} />
<ItemComponent id={1} />
</>
)
}
Що відбувається:
ItemComponent(1)викликає селектор зid=1. Кешується результат для1.ItemComponent(2)викликає селектор зid=2. Аргументи змінилися! Кеш перезаписується для2.ItemComponent(1)(ре-рендер) викликає селектор зid=1. Аргументи змінилися (порівняно з2)! Кеш перезаписується.
Результат: Мемоізація повністю зламана. Селектор постійно перераховується. Це називається Selector Thrashing.
Частина 7: Factory Pattern (Рішення для Shared Cache)
Щоб вирішити проблему shared cache, кожному екземпляру компонента потрібен власний екземпляр селектора.
Ми використовуємо патерн Factory Function — функція, яка створює селектор.
// 1. Створюємо функцію-фабрику (make...)
const makeSelectItemById = () => {
// 2. Всередині створюємо НОВИЙ createSelector
return createSelector([(state) => state.items, (state, id) => id], (items, id) => {
console.log('Complex finding logic...')
return items.find((i) => i.id === id)
})
}
Використання в компоненті з useMemo:
function ItemComponent({ id }) {
// 1. Створюємо унікальний селектор для ЦЬОГО компонента
// useMemo гарантує, що селектор створиться лише один раз на життя компонента
const selectItemById = useMemo(() => makeSelectItemById(), []) // [] - без залежностей
// 2. Викликаємо цей унікальний селектор
const item = useSelector((state) => selectItemById(state, id))
return <div>{item?.name}</div>
}
Тепер:
- Компонент A (id=1) має свій селектор. Для нього аргументи
(state, 1)стабільні. - Компонент B (id=2) має свій селектор. Для нього аргументи
(state, 2)стабільні.
Мемоізація працює ідеально!
Advanced Factory with Props that Change
Що робити, якщо пропси змінюються? (наприклад, filterType).
const makeSelectVisibleTodos = () => {
return createSelector([(state) => state.todos, (state, props) => props.filterType], (todos, filterType) => {
switch (filterType) {
case 'SHOW_COMPLETED':
return todos.filter((t) => t.completed)
case 'SHOW_ACTIVE':
return todos.filter((t) => !t.completed)
default:
return todos
}
})
}
function VisibleTodoList({ filterType }) {
const selectVisibleTodos = useMemo(makeSelectVisibleTodos, [])
const todoList = useSelector((state) => selectVisibleTodos(state, { filterType }))
// ...
}
Якщо filterType зміниться, селектор просто перерахує значення і оновить свій внутрішній кеш.
Частина 8: Структуровані Селектори (Structured Selectors)
Часто компоненту потрібно отримати декілька шматків даних. Початківці роблять так:
// ❌ 3 виклики хука
const user = useSelector(selectUser)
const theme = useSelector(selectTheme)
const notifications = useSelector(selectNotifications)
Або так:
// ❌ Повертає новий об'єкт щоразу -> re-render
const { user, theme } = useSelector((state) => ({
user: state.auth.user,
theme: state.ui.theme,
}))
Рішення 1: shallowEqual
Можна використати функцію порівняння.
import { shallowEqual, useSelector } from 'react-redux'
const { user, theme } = useSelector(
(state) => ({
user: state.auth.user,
theme: state.ui.theme,
}),
shallowEqual,
) // ✅ Порівнює вміст об'єкта, а не посилання
Рішення 2: Структурований селектор з ReselectcreateSelector може повертати об'єкти, і оскільки він мемоізований, посилання на цей об'єкт буде стабільним.
const selectUserData = createSelector([selectUser, selectTheme], (user, theme) => ({
user,
theme,
isDark: theme === 'dark',
}))
// В компоненті
const { user, isDark } = useSelector(selectUserData)
Це краще, тому що селектор може інкапсулювати логіку комбінування даних.
Частина 9: Рекурсивні Селектори
Робота з деревовидними структурами (наприклад, коментарі з вкладеністю) вимагає особливого підходу.
Припустимо, у нас є плоский стейт коментарів (нормалізований):
const state = {
comments: {
1: { id: 1, text: 'Hello', parentId: null },
2: { id: 2, text: 'World', parentId: 1 },
3: { id: 3, text: '!!!!!', parentId: 2 },
},
}
Ми хочемо побудувати дерево.
const selectComments = (state) => state.comments
const selectCommentTree = createSelector([selectComments], (comments) => {
// 1. Створюємо мапу
const map = {}
const roots = []
Object.values(comments).forEach((c) => {
map[c.id] = { ...c, children: [] } // Створюємо копію з children
})
// 2. Будуємо дерево
Object.values(map).forEach((c) => {
if (c.parentId && map[c.parentId]) {
map[c.parentId].children.push(c)
} else {
roots.push(c)
}
})
return roots // Повертає дерево
})
Цей селектор поверне нове дерево щоразу, коли зміниться хоча б один коментар. Це нормально для більшості випадків. Але якщо дерево величезне, можна оптимізувати це, мемоізуючи окремі гілки, хоча це значно ускладнює код.
Частина 10: Reselect 5.0 та Сучасні Фічі
У 2023 році вийшов Reselect 5.0 (частина RTK 2.0). Він приніс важливі інструменти для дебаггінгу.
Dev Mode Checks
У режимі розробки (process.env.NODE_ENV !== 'production') Reselect тепер автоматично перевіряє ваші селектори на типові помилки.
- Input Stability Check:
Він запускає input selectors двічі. Якщо вони повертають різні посилання при однакових аргументах, він викидає попередження.
// ⚠️ Console Warn in Dev: The result function returned a new reference... const selectBad = createSelector( [(state) => state.items.map((i) => i.id)], // map повертає новий масив! (ids) => ids, )
Ви можете вимкнути це:createSelector(..., { devModeChecks: { inputStabilityCheck: 'never' } }). - Identity Function Check:
Якщо Result Function просто повертає значення аргументу без змін.
createSelector([selectData], (data) => data) // Навіщо тут createSelector?
Частина 11: TypeScript Інтеграція
Reselect написаний на TS, тому типізація чудова, але іноді складна.
Базова типізація
import { RootState } from './store'
// Явно вказуємо тип state
const selectCount = (state: RootState) => state.counter.value
// createSelector виводить типи автоматично
const selectDoubleCount = createSelector(
[selectCount],
(count) => count * 2, // TS знає, що count це number
)
Типізація селекторів з пропсами
// Типізуємо пропси
const selectItemById = createSelector(
[
(state: RootState) => state.items,
(state: RootState, itemId: string) => itemId, // Вказуємо тип другого аргументу
],
(items, itemId) => items.find((i) => i.id === itemId),
)
Типізація Output Selector
Іноді вам потрібно експортувати тип самого селектора.
import { OutputSelector } from '@reduxjs/toolkit';
export const selectUser: OutputSelector<
[typeof selectAuth], // Inputs
User, // Result
(res: AuthState) => User // Combiner
> = createSelector(...)
(Зазвичай typeof selectUser достатньо).
Типізація Factory Selector
export const makeSelectUserById = () => {
return createSelector([selectUsers, (state: RootState, id: number) => id], (users, id) => users[id])
}
// Тип результату фабрики
type SelectUserById = ReturnType<typeof makeSelectUserById>
Частина 12: Архітектура та Розміщення
Де зберігати селектори? Є два підходи.
Підхід 1. Колокація (Colocation - Рекомендовано)
Зберігайте селектори поруч з редюсером у файлі слайсу.
// features/users/usersSlice.js
const usersSlice = createSlice({ ... });
// Приватні input selectors
const selectUsersState = state => state.users;
// Публічні експортовані селектори
export const selectAllUsers = createSelector(
[selectUsersState],
state => state.ids.map(id => state.entities[id])
);
export default usersSlice.reducer;
Підхід 2. Окремі файли
Для великих додатків можна винести селектори в usersSelectors.js. Це допомагає уникнути циклічних залежностей, якщо селектори одного слайсу залежать від іншого.
RTK 2.0 selectors поле
В новій версії RTK ви можете визначати селектори прямо в createSlice:
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: { ... },
// Нове поле!
selectors: {
selectDouble: (state) => state.value * 2,
selectQuadruple: (state) => state.value * 4,
},
});
// Автоматичне генерування імен (але можна деструктурувати)
export const { selectDouble, selectQuadruple } = counterSlice.selectors;
Важливо: state тут — це локальний стейт слайсу, а не глобальний RootState.
Частина 13: Типові Помилки та Anti-Patterns
1. Створення селекторів всередині компонента
// ❌ ЖАХ
function UserProfile() {
// Цей селектор створюється заново при кожному рендері
// Його кеш завжди порожній!
const selectUser = createSelector(...);
const user = useSelector(selectUser);
}
Рішення: Винесіть селектор за межі компонента або використовуйте useMemo (Factory Pattern).
2. Використання createSelector для простих операцій
// 😐 ПЕРЕБІР
const selectCount = createSelector([(state) => state.counter], (counter) => counter.value)
Це оверхед. Прості селектори state => state.value працюють швидше, бо виклик функції дешевший за логіку мемоізації. Використовуйте createSelector тільки якщо є:
- Трансформація даних (map, filter).
- Об'єднання кількох слайсів.
- Створення нових об'єктів/масивів.
3. Повернення undefined для дефолтних значень
// ❌ Може повернути новий об'єкт {} при кожному виклику, якщо юзера немає
const selectUser = createSelector([selectState], (state) => state.user || {})
Рішення: Винесіть дефолтне значення в константу.
const EMPTY_USER = {}
const selectUser = createSelector([selectState], (state) => state.user || EMPTY_USER)
Частина 14: Debugging Selectors
Що робити, якщо селектор не мемоізується, і ви не розумієте чому?
1. Re-reselect
Існує бібліотека re-reselect (або просто додайте логування), яка дозволяє перевірити кеш.
Але простіше додати лог в сам селектор:
const selectComplex = createSelector([input1, input2], (res1, res2) => {
console.log('Recalculating!', { res1, res2 }) // Якщо ви бачите це занадто часто — проблема в inputs
return res1 + res2
})
2. React Profiler
- Відкрийте React DevTools -> Profiler.
- Запишіть дію.
- Подивіться на графік "Commits".
- Якщо компонент ре-рендериться, наведіть на нього. React скаже "Why did this render?".
- Якщо причина "Hooks changed", і це
useSelector— ваш селектор повернув нове посилання.
3. Debugging Input Output
Ви можете перевірити кількість ре-обчислень:
selectComplex.recomputations() // Кількість разів, коли resultFunc виконувалась
selectComplex.resetRecomputations() // Скинути лічильник
Це корисно в тестах або консолі.
Частина 15: Тестування Селекторів
Оскільки селектори — це чисті функції, тестувати їх одне задоволення. Вам не потрібен Redux Store, компоненти чи моки. Просто вхідні дані і очікуваний результат.
// selectors.test.js
import { selectVisibleOrders } from './ordersSlice'
describe('selectVisibleOrders', () => {
it('should filter by status', () => {
const orders = [
{ id: 1, status: 'pending' },
{ id: 2, status: 'completed' },
]
const filters = { status: 'pending', sort: 'date' }
// Викликаємо result function напряму для ізольованого тестування!
// createSelector.resultFunc доступна для тестів
const result = selectVisibleOrders.resultFunc(orders, filters)
expect(result).toHaveLength(1)
expect(result[0].id).toBe(1)
})
it('should act as a memoized selector', () => {
const state = {
/* ... */
}
const result1 = selectVisibleOrders(state)
const result2 = selectVisibleOrders(state)
expect(result1).toBe(result2) // Reference equality check
})
})
Або тестуйте як звичайну функцію, передаючи повний стейт:
const state = {
orders: { list: [...], filters: {...} }
};
const result = selectVisibleOrders(state);
Частина 16: FAQ (Часті запитання)
П: Чи можна використовувати async в селекторах?
В: НІ! Селектори повинні бути чистими синхронними функціями. Якщо вам потрібно робити асинхронні запити, використовуйте Thunks або RTK Query. Селектор працює тільки з тим, що вже є в стейті.
П: Чи можна використовувати хуки всередині селекторів?
В: НІ! Селектор — це звичайна функція JS. Хуки (useSelector, useState) працюють тільки всередині компонентів або інших хуків.
П: Як передати 3+ аргументів у селектор?
В: Передавайте аргументи як об'єкт.
const selectItem = (state, { id, type, role }) => ...
Аргументи для createSelector будуть: (state, props) => props.xyz.
П: Чи працює Reselect з Context API або Zustland?
В: Так! createSelector — це незалежна утиліта.
- Zustand: Можна використовувати для derived state.
- Context API: Можна мемоізувати значення контексту.
// Zustand example
import { createStore } from 'zustand';
import { createSelector } from 'reselect'; // або @reduxjs/toolkit
const useStore = createStore((set) => ({ ... }));
const selectDoubled = createSelector(
[state => state.count],
count => count * 2
);
// В компоненті
const doubled = useStore(selectDoubled);
П: Чи варто мемоізувати все підряд?
В: Ні. Мемоізація має свою ціну (пам'ять, перевірки). Не мемоізуйте прості доступи (state => state.x) або дуже дешеві операції (a + b). Мемоізуйте:
- Фільтрацію масивів.
- Сортування.
- Мапінг великих списків.
- Складну математику.
Частина 17: Benchmarking Script
Хочете перевірити, наскільки селектори швидші? Запустіть цей скрипт у Node.js.
/* benchmark.js */
const { createSelector } = require('@reduxjs/toolkit')
// 1. Створимо великий масив
const ITEMS_COUNT = 100000
const state = {
items: Array.from({ length: ITEMS_COUNT }, (_, i) => ({
id: i,
completed: Math.random() > 0.5,
value: Math.random() * 100,
})),
}
// 2. Звичайний селектор
const selectBad = (state) => state.items.filter((i) => i.completed)
// 3. Мемоізований селектор
const selectMemoized = createSelector([(state) => state.items], (items) => items.filter((i) => i.completed))
// 4. Тест продуктивності
console.time('Bad Selector (First Run)')
selectBad(state)
console.timeEnd('Bad Selector (First Run)')
console.time('Bad Selector (Second Run)')
selectBad(state) // Перераховує знову!
console.timeEnd('Bad Selector (Second Run)')
console.time('Memoized (First Run)')
selectMemoized(state)
console.timeEnd('Memoized (First Run)')
console.time('Memoized (Second Run)')
selectMemoized(state) // Миттєво!
console.timeEnd('Memoized (Second Run)')
/*
Очікуваний результат:
Bad Selector (First Run): 5.2ms
Bad Selector (Second Run): 5.1ms
Memoized (First Run): 5.3ms
Memoized (Second Run): 0.005ms 🚀 (1000x швидше!)
*/
Частина 18: Кейс-стаді (High Frequency Updates)
Давайте розглянемо реальний приклад з фінансової сфери (крипто-біржа), де неправильні селектори "вбивають" CPU.
Сценарій
У нас є WebSocket, який присилає 100 оновлень цін на секунду. Стейт виглядає так:
/* state.market */
{
tickers: {
'BTC-USDT': { price: 45000.50, volume: 1000 },
'ETH-USDT': { price: 3200.10, volume: 5000 },
// ... ще 500 пар
},
favorites: ['BTC-USDT', 'DOGE-USDT'] // Список улюблених
}
Погана реалізація (Naive Approach)
// Компонент списку улюблених
function FavoritesList() {
// ❌ Цей селектор створює новий масив 100 разів на секунду!
const favorites = useSelector((state) => {
return state.market.favorites.map((symbol) => state.market.tickers[symbol])
})
return (
<div>
{favorites.map((ticker) => (
<TickerRow key={ticker.symbol} data={ticker} />
))}
</div>
)
}
Що відбувається?
- Приходить оновлення ціни для пари
XRP-USDT(якої навіть немає вfavorites). - Redux стейт оновлюється (нове посилання на
state.market.tickers). useSelectorзапускається.- Він робить
.map(...)і створює новий масив об'єктів (навіть якщо ціни BTC і DOGE не змінилися!). - Новий масив !== старий масив.
FavoritesListре-рендериться 100 разів на секунду.- Інтерфейс "фризить".
Оптимізована реалізація (Reselect)
Ми повинні ізолювати дані. Якщо змінився XRP, селектор favorites не повинен перераховуватись.
// 1. Input selectors
const selectTickers = (state) => state.market.tickers
const selectFavoritesIDs = (state) => state.market.favorites
// 2. Memoized selector
const selectFavoriteTickers = createSelector(
[selectTickers, selectFavoritesIDs],
(tickers, favoriteIds) => {
return favoriteIds.map((id) => tickers[id])
},
{
// 🚨 Цього все ще НЕДОСТАТНЬО!
// Якщо tickers змінився (через XRP), то перший аргумент змінився.
// Reselect скине кеш і перерахує масив.
},
)
Ми все ще маємо проблему! state.market.tickers — це один великий об'єкт. Будь-яка зміна будь-якої ціни змінює посилання на цей об'єкт.
Рішення: Granular Selection (Factory Pattern)
Замість того, щоб вибирати весь список, компонент списку повинен вибирати тільки ID, а дочірні компоненти — тільки свої дані.
// Father Component
function FavoritesList() {
// ✅ Цей масив змінюється рідко (тільки коли юзер додає/видаляє зірок)
const favoriteIds = useSelector((state) => state.market.favorites)
return (
<div>
{favoriteIds.map((id) => (
<MemoizedTickerRow key={id} symbol={id} />
))}
</div>
)
}
// Child Component
const makeSelectTickerData = () => {
return createSelector(
[(state) => state.market.tickers, (state, symbol) => symbol],
(tickers, symbol) => tickers[symbol],
)
}
const TickerRow = ({ symbol }) => {
// ✅ Factory pattern: кожен рядок має свій селектор
const selectTickerData = useMemo(makeSelectTickerData, [])
const data = useSelector((state) => selectTickerData(state, symbol))
return (
<div>
{symbol}: {data.price}
</div>
)
}
// Не забуваємо React.memo, щоб батьківський рендер не чіпляв дітей
const MemoizedTickerRow = React.memo(TickerRow)
Результат:
- Приходить оновлення
XRP. state.market.tickersоновлюється.FavoritesListдивиться наstate.market.favorites. Він не змінився. Рендер списку не відбувається.- Всі
TickerRow(BTC, DOGE) перевіряють свої селектори. selectTickerDataдля BTC дивиться:tickersзмінилися? Так.symbolзмінився? Ні.- Він лізе в
tickers['BTC-USDT']. Порівнює об'єкт ціни з попереднім. - Якщо об'єкт ціни BTC той самий (посилально), селектор поверне старе посилання?
Стоп! Тут нюанс. Якщо ми мутуємо
tickersправильно (через Immer), тоtickers['BTC']залишиться тим самим об'єктом, якщо ми не чіпали його поля.
Отже, якщо оновився тільки XRP, то об'єкт BTC залишився старим посиланням.selectTickerDataповерне старий результат.useSelectorне викличе ре-рендерTickerRow(BTC).
Вся система залишається спокійною, навіть при шаленому потоці даних. Оновлюється (ре-рендериться) тільки TickerRow(XRP), якщо він є на екрані.
Висновок
Селектори — це не просто спосіб взяти дані. Це фундамент продуктивності React-Redux додатку.
- Використовуйте
useSelectorз розумом: Слідкуйте за референційною цілісністю. - Мемоізуйте складні операції:
createSelectorваш найкращий друг. - Компонуйте: Будуйте складні селектори з простих.
- Factory Pattern: Не забувайте про нього, коли селектору потрібні пропси.
- Нормалізація: Селектори працюють найкраще з нормалізованим стейтом (Entity Adapter).
Опанувавши селектори, ви зможете будувати додатки, які залишаються швидкими навіть при тисячах елементів і складній бізнес-логіці.
Quiz: Перевірка знань
Наступна тема: RTK Query
04. Entity Adapter: Керування нормалізованим станом
RTK Query: Архітектура Серверного Кешу
RTK Query — це не просто "fetcher" даних. Це повноцінний Server State Manager, який інтегрований у Redux Toolkit. Він перевертає уявлення про те, як ми працюємо з даними на клієнті, зміщуючи фокус з "ручного управління loading/error/data" на "декларативне описування залежностей".