Advanced

Мемоізація та Селектори: Повний Гайд по Reselect

У світі Redux селектори часто недооцінюють. Багато розробників сприймають їх просто як функції для отримання шматочка даних зі стейту. Але насправді, селектори — це потужний шар абстракції, який відповідає за ефективність, інкамисуляцію та обчислення похідних даних.

Мемоізація та Селектори: Повний Гайд по 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 (Шар Запитів) для вашого стейту.

Loading diagram...

graph TD UIReact Components -->|Викликають| SLSelectors Layer SL -->|Читає| RSRedux State

subgraph "Query Logic"
SL
end

style SL fill:#3b82f6,stroke:#1d4ed8,color:#ffffff

Чому це важливо?

  1. Інкапсуляція: Компоненти не повинні знати про структуру стейту. Якщо ви зміните структуру state.user на state.auth.user, вам потрібно буде оновити лише селектор, а не 50 компонентів.
  2. Похідні дані (Derived Data): Стейт повинен зберігати мінімально необхідні дані. Все інше (відфільтровані списки, суми, форматовані дати) має обчислюватися "на льоту" в селекторах.
  3. Продуктивність: Правильні селектори запобігають зайвим ре-рендерам.

Принцип "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:

  1. Запускає вашу функцію-селектор.
  2. Порівнює результат з попереднім результатом.
  3. Якщо результат змінився (нове посилання), він змушує компонент ре-рендеритися.
  4. Якщо результат той самий (те саме посилання), нічого не відбувається.

Порівняння відбувається через сувору рівність (===).

Пастка нових посилань

Розглянемо найпоширенішу помилку:

// ❌ ПОГАНО: Завжди повертає нове посилання
const selectActiveTodos = (state) => {
    // .filter() ЗАВЖДИ створює новий масив, навіть якщо вміст не змінився!
    return state.todos.filter((todo) => !todo.completed)
}

Що відбувається під капотом:

  1. Екшен: dispatch({ type: 'UI/THEME_CHANGED' }).
  2. Redux стейт оновлюється (змінилася тема, але todos ті самі).
  3. useSelector запускає selectActiveTodos.
  4. state.todos.filter(...) запускається і створює новий масив Array(5).
  5. Попередній результат був Array(5).
  6. Перевірка: newArray === oldArray -> false.
  7. 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)
    },
)

Алгоритм роботи:

  1. Виклик Input Selectors: Коли ви викликаєте selectMemoizedData(state), Reselect спочатку запускає всі функції з масиву [A].
  2. Перевірка змін: Він порівнює результати цих функцій з попередніми результатами (saved arguments).
  3. Кеш-хіт (Cache Hit): Якщо результати input selectors ідентичні (===) попереднім, Reselect не запускає Result Function [B] і одразу повертає збережений результат.
  4. Кеш-міс (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):

  1. selectTodos поверне той самий масив todos (бо він не змінився).
  2. createSelector побачить, що аргумент не змінився.
  3. Він поверне той самий масив (посилання), що й минулого разу.
  4. 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.

  1. selectSearchQuery -> Нове значення.
  2. selectFilteredTodos -> Input змінився -> Перераховує фільтрацію -> Новий масив todos.
  3. selectTodoStats -> Input змінився -> Перераховує статистику.
  4. selectTodoListViewModel -> Inputs змінилися -> Оновлює View Model.
  5. React UI оновлюється.

А тепер припустимо, користувач змінив своє ім'я в профілі (user.name).

  1. selectSearchQuery -> Те саме. selectAllTodos -> Те саме. selectFilter -> Те саме.
  2. selectFilteredTodos -> Inputs не змінилися -> Cache Hit. Повертає старий масив.
  3. selectTodoStats -> Input (результат попереднього) не змінився -> Cache Hit.
  4. selectTodoListViewModel -> Inputs не змінилися -> Cache Hit.
  5. 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} />
        </>
    )
}

Що відбувається:

  1. ItemComponent(1) викликає селектор з id=1. Кешується результат для 1.
  2. ItemComponent(2) викликає селектор з id=2. Аргументи змінилися! Кеш перезаписується для 2.
  3. 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) стабільні.

Мемоізація працює ідеально!

Починаючи з React-Redux v7+, це рекомендований патерн для селекторів, залежних від пропсів.

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 тепер автоматично перевіряє ваші селектори на типові помилки.

  1. 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' } }).
  2. 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

  1. Відкрийте React DevTools -> Profiler.
  2. Запишіть дію.
  3. Подивіться на графік "Commits".
  4. Якщо компонент ре-рендериться, наведіть на нього. React скаже "Why did this render?".
  5. Якщо причина "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). Мемоізуйте:

  1. Фільтрацію масивів.
  2. Сортування.
  3. Мапінг великих списків.
  4. Складну математику.

Частина 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>
    )
}

Що відбувається?

  1. Приходить оновлення ціни для пари XRP-USDT (якої навіть немає в favorites).
  2. Redux стейт оновлюється (нове посилання на state.market.tickers).
  3. useSelector запускається.
  4. Він робить .map(...) і створює новий масив об'єктів (навіть якщо ціни BTC і DOGE не змінилися!).
  5. Новий масив !== старий масив.
  6. FavoritesList ре-рендериться 100 разів на секунду.
  7. Інтерфейс "фризить".

Оптимізована реалізація (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)

Результат:

  1. Приходить оновлення XRP.
  2. state.market.tickers оновлюється.
  3. FavoritesList дивиться на state.market.favorites. Він не змінився. Рендер списку не відбувається.
  4. Всі TickerRow (BTC, DOGE) перевіряють свої селектори.
  5. selectTickerData для BTC дивиться: tickers змінилися? Так. symbol змінився? Ні.
  6. Він лізе в tickers['BTC-USDT']. Порівнює об'єкт ціни з попереднім.
  7. Якщо об'єкт ціни BTC той самий (посилально), селектор поверне старе посилання? Стоп! Тут нюанс. Якщо ми мутуємо tickers правильно (через Immer), то tickers['BTC'] залишиться тим самим об'єктом, якщо ми не чіпали його поля.
    Отже, якщо оновився тільки XRP, то об'єкт BTC залишився старим посиланням.
    selectTickerData поверне старий результат. useSelector не викличе ре-рендер TickerRow(BTC).

Вся система залишається спокійною, навіть при шаленому потоці даних. Оновлюється (ре-рендериться) тільки TickerRow(XRP), якщо він є на екрані.


Висновок

Селектори — це не просто спосіб взяти дані. Це фундамент продуктивності React-Redux додатку.

  1. Використовуйте useSelector з розумом: Слідкуйте за референційною цілісністю.
  2. Мемоізуйте складні операції: createSelector ваш найкращий друг.
  3. Компонуйте: Будуйте складні селектори з простих.
  4. Factory Pattern: Не забувайте про нього, коли селектору потрібні пропси.
  5. Нормалізація: Селектори працюють найкраще з нормалізованим стейтом (Entity Adapter).

Опанувавши селектори, ви зможете будувати додатки, які залишаються швидкими навіть при тисячах елементів і складній бізнес-логіці.

Quiz: Перевірка знань

Чи зрозуміли ви різницю між Input та Output selectors? Пройдіть тест!

Наступна тема: RTK Query

Як забути про ручне написання thunks і селекторів для API запитів.
Copyright © 2026