Redux Toolkit

createSlice: Революція в Redux

Пам'ятаєте, як ми створювали окремі файли для constants, actions та reducers? Цей час минув. createSlice — це функція, яка об'єднує всю логіку feature в одному місці і робить це елегантно.

createSlice: Революція в Redux

Пам'ятаєте, як ми створювали окремі файли для constants, actions та reducers? Цей час минув. createSlice — це функція, яка об'єднує всю логіку feature в одному місці і робить це елегантно.

Проблема класичного Redux

Давайте згадаємо, скільки коду потрібно було писати раніше:

constants/actionTypes.js
export const INCREMENT = 'counter/INCREMENT'
export const DECREMENT = 'counter/DECREMENT'
export const INCREMENT_BY_AMOUNT = 'counter/INCREMENT_BY_AMOUNT'

Проблеми: 3 файли, багато boilerplate, ручне управління іммутабельністю ({ ...state }), легко зробити помилку.

Рішення: createSlice

Той самий функціонал в одному файлі:

features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
    name: 'counter',
    initialState: { value: 0 },
    reducers: {
        increment: (state) => {
            state.value += 1 // Мутація! Але це безпечно завдяки Immer
        },
        decrement: (state) => {
            state.value -= 1
        },
        incrementByAmount: (state, action) => {
            state.value += action.payload
        },
    },
})

export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer

Переваги: 1 файл, мутабельний синтаксис (простіше читати), автоматична генерація actions.


Анатомія Slice

Loading diagram...
graph TB
    A[createSlice] --> B[name: 'counter']
    A --> C[initialState]
    A --> D[reducers]
    A --> E["extraReducers (optional)"]
    A --> F["selectors (RTK 2.0+)"]

    D --> G[increment]
    D --> H[decrement]
    D --> I[incrementByAmount]

    G --> J[Auto-generated Action Creator]
    H --> K[Auto-generated Action Creator]
    I --> L[Auto-generated Action Creator]

    J --> M["{ type: 'counter/increment' }"]
    K --> N["{ type: 'counter/decrement' }"]
    L --> O["{ type: 'counter/incrementByAmount', payload }"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#10b981,stroke:#059669,color:#ffffff
    style E fill:#64748b,stroke:#334155,color:#ffffff
    style F fill:#64748b,stroke:#334155,color:#ffffff

Повний API createSlice

Параметри конфігурації

ПараметрТипОбов'язковийОпис
nameStringПрефікс для action types ('counter''counter/increment')
initialStateAnyПочатковий стан slice
reducersObjectОб'єкт із синхронними reducer функціями
extraReducersFunction/ObjectОбробка actions з інших slices або thunks
selectorsObject(RTK 2.0+) Автоматична генерація селекторів
Порада: name має бути унікальним в додатку. Використовуйте назву feature (наприклад, 'auth', 'todos', 'posts').

Що генерується автоматично?

Після виклику createSlice ви отримуєте:

const slice = createSlice({
    /* config */
})

// Доступні поля:
slice.name // 'counter'
slice.reducer // Функція-reducer для store
slice.actions // { increment, decrement, ... }
slice.caseReducers // Внутрішні reducers (для advanced use cases)
slice.getSelectors // (RTK 2.0+) Фабрика селекторів

Reducers: Patterns та Приклади

1. Простий reducer без payload

reducers: {
  reset: (state) => {
    state.value = 0; // Просто встановлюємо значення
  },

  toggleDarkMode: (state) => {
    state.isDarkMode = !state.isDarkMode; // Інверсія boolean
  },
}

Використання:

dispatch(reset()) // { type: 'counter/reset' }
dispatch(toggleDarkMode()) // { type: 'settings/toggleDarkMode' }

2. Reducer з payload

reducers: {
  setUser: (state, action) => {
    state.user = action.payload; // payload — це передане значення
  },

  addTodo: (state, action) => {
    state.todos.push(action.payload); // Immer дозволяє push
  },
}

Використання:

dispatch(setUser({ id: 1, name: 'John' }))
// action: { type: 'auth/setUser', payload: { id: 1, name: 'John' } }

dispatch(addTodo({ id: Date.now(), text: 'Learn Redux' }))
// action: { type: 'todos/addTodo', payload: { ... } }

3. Payload Prepare (кастомізація action)

Іноді потрібно обробити аргументи перед створенням action:

reducers: {
  addTodo: {
    reducer: (state, action) => {
      state.todos.push(action.payload);
    },
    prepare: (text) => {
      // Викликається при dispatch(addTodo('text'))
      return {
        payload: {
          id: Date.now(),
          text,
          completed: false,
          createdAt: new Date().toISOString(),
        },
      };
    },
  },
}

Використання:

dispatch(addTodo('Learn Redux'))
// action: {
//   type: 'todos/addTodo',
//   payload: { id: 1675000000, text: 'Learn Redux', completed: false, ... }
// }
Use case: Генерація ID, timestamps, валідація даних перед dispatch.

4. Робота з масивами

const todosSlice = createSlice({
    name: 'todos',
    initialState: { items: [] },
    reducers: {
        // Додавання
        addTodo: (state, action) => {
            state.items.push(action.payload) // Immer дозволяє push
        },

        // Видалення за ID
        removeTodo: (state, action) => {
            state.items = state.items.filter((todo) => todo.id !== action.payload)
        },

        // Оновлення елемента
        toggleTodo: (state, action) => {
            const todo = state.items.find((t) => t.id === action.payload)
            if (todo) {
                todo.completed = !todo.completed // Пряма мутація елемента
            }
        },

        // Заміна всього масиву
        setTodos: (state, action) => {
            state.items = action.payload
        },

        // Сортування
        sortTodos: (state) => {
            state.items.sort((a, b) => a.text.localeCompare(b.text))
        },
    },
})

5. Вкладені об'єкти (Nested state)

const userSlice = createSlice({
    name: 'user',
    initialState: {
        profile: {
            name: '',
            email: '',
            settings: {
                notifications: true,
                theme: 'light',
            },
        },
        isLoading: false,
    },
    reducers: {
        updateProfile: (state, action) => {
            // Immer дозволяє глибоку мутацію
            state.profile.name = action.payload.name
            state.profile.email = action.payload.email
        },

        toggleNotifications: (state) => {
            state.profile.settings.notifications = !state.profile.settings.notifications
        },

        setTheme: (state, action) => {
            state.profile.settings.theme = action.payload
        },
    },
})

6. Conditional logic в reducer

reducers: {
  incrementIfOdd: (state) => {
    if (state.value % 2 !== 0) {
      state.value += 1;
    }
  },

  incrementWithLimit: (state) => {
    if (state.value < 100) {
      state.value += 1;
    }
  },

  addItem: (state, action) => {
    // Перевірка на дублікати
    const exists = state.items.some(item => item.id === action.payload.id);
    if (!exists) {
      state.items.push(action.payload);
    }
  },
}

7. Множинні оновлення за один action

reducers: {
  login: (state, action) => {
    state.user = action.payload.user;
    state.token = action.payload.token;
    state.isAuthenticated = true;
    state.error = null;
  },

  logout: (state) => {
    state.user = null;
    state.token = null;
    state.isAuthenticated = false;
  },
}

8. Повернення нового стану (альтернатива мутації)

reducers: {
  // Мутація (рекомендовано)
  increment: (state) => {
    state.value += 1;
  },

  // Повернення нового об'єкта (класичний Redux стиль)
  reset: () => {
    return { value: 0 }; // Повний reset
  },

  // ❌ НЕ РОБІТЬ ТАК (мутація + return)
  wrongWay: (state) => {
    state.value += 1;
    return state; // ПОМИЛКА!
  },
}
Правило Immer: Або мутуйте state, або поверніть новий state. Ніколи разом!

Immer: Як це працює?

Концепція Draft State

Loading diagram...
sequenceDiagram
    participant R as Reducer
    participant I as Immer
    participant D as Draft State
    participant S as Original State

    R->>I: Викликає reducer з мутаціями
    I->>D: Створює Proxy Draft
    R->>D: state.value += 1 (запис)
    D->>D: Записує зміни
    I->>I: Аналізує зміни
    I->>S: Створює новий іммутабельний state
    I->>R: Повертає новий state

    Note over I,D: Draft — це Proxy, який<br/>відстежує всі зміни

Що робить Immer під капотом?

  1. Створює Proxy навколо вашого state
  2. Записує всі зміни які ви робите
  3. Генерує новий state з вашими змінами
  4. Зберігає структурний sharing (незмінені частини залишаються тими самими об'єктами)
// Що ви пишете:
state.user.profile.name = 'John'

// Що Immer робить під капотом (концептуально):
return {
    ...state,
    user: {
        ...state.user,
        profile: {
            ...state.user.profile,
            name: 'John',
        },
    },
}

Переваги Immer

🎯 Простіший код

Мутабельний синтаксис набагато легше читати і писати

🛡️ Іммутабельність

Під капотом все залишається іммутабельним

⚡ Performance

Structural sharing оптимізує пам'ять

🐛 Менше помилок

Не треба думати про spread operators

Обмеження Immer

Що НЕ можна робити:
  1. Присвоювання state = чогось
    // ❌ Не працює
    reducers: {
      bad: (state) => {
        state = { newState }; // Не змінить state!
      },
    }
    
    // ✅ Правильно
    reducers: {
      good: (state) => {
        Object.assign(state, { newState }); // Або
        return { newState }; // Поверніть новий
      },
    }
    
  2. Асинхронний код всередині reducers
    // ❌ Заборонено
    reducers: {
      bad: async (state) => {
        const data = await fetch('/api'); // NO!
      },
    }
    
  3. Side effects (console.log, dispatch, тощо)
    // ❌ Погано
    reducers: {
      bad: (state) => {
        console.log('Updating...'); // Технічно працює, але анти-паттерн
        state.value += 1;
      },
    }
    

extraReducers: Обробка зовнішніх actions

extraReducers використовується для:

  • Обробки actions з інших slices
  • Обробки async thunks (createAsyncThunk)
  • Обробки actions, які не створені в цьому slice

Builder Callback Pattern (рекомендовано)

import { createSlice } from '@reduxjs/toolkit'
import { fetchUserById } from './userThunks'
import { logout } from './authSlice'

const userSlice = createSlice({
    name: 'user',
    initialState: {
        data: null,
        loading: 'idle',
        error: null,
    },
    reducers: {
        setUser: (state, action) => {
            state.data = action.payload
        },
    },
    extraReducers: (builder) => {
        builder
            // Обробка async thunk
            .addCase(fetchUserById.pending, (state) => {
                state.loading = 'pending'
                state.error = null
            })
            .addCase(fetchUserById.fulfilled, (state, action) => {
                state.loading = 'succeeded'
                state.data = action.payload
            })
            .addCase(fetchUserById.rejected, (state, action) => {
                state.loading = 'failed'
                state.error = action.error.message
            })
            // Обробка action з іншого slice
            .addCase(logout, (state) => {
                state.data = null
                state.loading = 'idle'
            })
    },
})

Map Object Pattern (застарілий, але працює)

extraReducers: {
  [fetchUserById.pending]: (state) => {
    state.loading = 'pending';
  },
  [fetchUserById.fulfilled]: (state, action) => {
    state.data = action.payload;
  },
  [logout.type]: (state) => {
    state.data = null;
  },
}
Рекомендація: Завжди використовуйте builder pattern. Він має кращу TypeScript підтримку та autocomplete.

Matcher для групової обробки

extraReducers: (builder) => {
    builder
        // Обробити всі pending actions
        .addMatcher(
            (action) => action.type.endsWith('/pending'),
            (state) => {
                state.loading = true
            },
        )
        // Обробити всі fulfilled
        .addMatcher(
            (action) => action.type.endsWith('/fulfilled'),
            (state) => {
                state.loading = false
            },
        )
        // Default case (на кінці)
        .addDefaultCase((state, action) => {
            // Логування невідомих actions для debugging
        })
}

Auto-generated Actions

Структура згенерованого action

const slice = createSlice({
    name: 'counter',
    initialState: { value: 0 },
    reducers: {
        increment: (state) => {
            state.value += 1
        },
        incrementByAmount: (state, action) => {
            state.value += action.payload
        },
    },
})

// Згенеровані action creators:
slice.actions.increment()
// → { type: 'counter/increment' }

slice.actions.incrementByAmount(5)
// → { type: 'counter/incrementByAmount', payload: 5 }

// Action type strings доступні:
slice.actions.increment.type // 'counter/increment'

Action Creator з prepare

Для складнішої логіки:

reducers: {
  createPost: {
    reducer: (state, action) => {
      state.posts.push(action.payload);
    },
    prepare: (title, content, userId) => {
      return {
        payload: {
          id: nanoid(), // Генерація ID
          title,
          content,
          userId,
          createdAt: new Date().toISOString(),
          reactions: { thumbsUp: 0, hooray: 0 },
        },
      };
    },
  },
}

// Використання:
dispatch(createPost('My Title', 'Content here', currentUserId));
prepare викликається до відправки action в store. Це ідеальне місце для логіки генерації ID, timestamps, або валідації.

Selectors в Slice (RTK 2.0+)

Нова фіча RTK 2.0 дозволяє визначати селектори прямо в slice:

const todosSlice = createSlice({
    name: 'todos',
    initialState: { items: [], filter: 'all' },
    reducers: {
        /* ... */
    },
    selectors: {
        selectAllTodos: (state) => state.items,
        selectFilter: (state) => state.filter,
        selectCompletedTodos: (state) => state.items.filter((todo) => todo.completed),
    },
})

// Експорт селекторів
export const { selectAllTodos, selectFilter, selectCompletedTodos } = todosSlice.selectors

Використання в компоненті:

import { useSelector } from 'react-redux'
import { selectAllTodos } from './todosSlice'

function TodoList() {
    const todos = useSelector((state) => selectAllTodos(state.todos))
    // ...
}
Селектори в slice автоматично приймають slice state (не root state), що робить їх reusable!

Patterns & Best Practices

1. Naming Conventions

// ✅ Добра назва slice
createSlice({ name: 'todos' })    // Множина для колекцій
createSlice({ name: 'auth' })     // Іменник для одиниці
createSlice({ name: 'ui' })       // Коротко та ясно

// ✅ Добрі назви reducers
reducers: {
  addTodo,          // Дієслово + іменник
  removeTodo,
  toggleCompleted,  // Дієслово + прикметник
  setFilter,        // set + назва поля
  reset,            // Коротке дієслово
}

// ❌ Погані назви
reducers: {
  todo,             // Незрозуміло, що робить
  update,           // Надто загально
  handleSubmit,     // "handle" — це UI logic
}

2. State Shape Design

// ✅ Добра структура
const initialState = {
    items: [], // Дані
    loading: 'idle', // UI state
    error: null, // Помилки
    filters: {
        // Логічне групування
        status: 'all',
        category: null,
    },
    pagination: {
        page: 1,
        pageSize: 20,
    },
}

// ❌ Погана структура
const initialState = {
    todos: [],
    isTodosLoading: false, // Префікси не потрібні (це вже todos slice)
    todosError: null,
    allStatus: 'all', // Незрозуміле ім'я
}

3. One Slice Per Feature

features/
├── todos/
│   ├── todosSlice.js       ← Вся логіка тут
│   ├── TodoList.jsx
│   └── TodoItem.jsx
├── auth/
│   ├── authSlice.js
│   └── LoginForm.jsx

Кожен slice незалежний і self-contained.

4. Composing Slices

Не дублюйте логіку між slices:

// ✅ Shared reducer logic
function createLoadingSlice(name, fetchFn) {
    return createSlice({
        name,
        initialState: { data: null, loading: 'idle', error: null },
        reducers: {},
        extraReducers: (builder) => {
            builder
                .addCase(fetchFn.pending, (state) => {
                    state.loading = 'pending'
                })
                .addCase(fetchFn.fulfilled, (state, action) => {
                    state.loading = 'succeeded'
                    state.data = action.payload
                })
                .addCase(fetchFn.rejected, (state, action) => {
                    state.loading = 'failed'
                    state.error = action.error.message
                })
        },
    })
}

// Використання
const usersSlice = createLoadingSlice('users', fetchUsers)
const postsSlice = createLoadingSlice('posts', fetchPosts)

Anti-patterns

1. Занадто великий slice

// ❌ Один slice для всього
const appSlice = createSlice({
    name: 'app',
    initialState: {
        user: {},
        todos: [],
        posts: [],
        comments: [],
        // ... ще 10 features
    },
    reducers: {
        // 50+ reducers тут
    },
})
Рішення: Розбийте на окремі slices (userSlice, todosSlice, postsSlice).

2. Логіка в компонентах

// ❌ Розрахунки в компоненті
function CartTotal() {
    const items = useSelector((state) => state.cart.items)
    const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    // ...
}
Рішення: Винесіть в селектор або додайте в state:
// ✅ Селектор
const selectCartTotal = createSelector(
  state => state.cart.items,
  items => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

// Або ✅ Computed state
reducers: {
  addItem: (state, action) => {
    state.items.push(action.payload);
    state.total = state.items.reduce(...); // Оновлюємо total
  },
}

3. Мутація поза Immer

// ❌ У компоненті
const handleClick = () => {
    const state = store.getState()
    state.counter.value++ // НІКОЛИ ТАК НЕ РОБІТЬ!
}

// ✅ Правильно
const handleClick = () => {
    dispatch(increment())
}

Real-World Examples


Migration Guide: Класичний Redux → createSlice

// constants/actionTypes.js
export const ADD_TODO = 'todos/ADD_TODO'
export const TOGGLE_TODO = 'todos/TOGGLE_TODO'

// actions/todoActions.js
export const addTodo = (text) => ({
    type: ADD_TODO,
    payload: { id: Date.now(), text, completed: false },
})

export const toggleTodo = (id) => ({
    type: TOGGLE_TODO,
    payload: id,
})

// reducers/todoReducer.js
const initialState = { items: [] }

export default function todosReducer(state = initialState, action) {
    switch (action.type) {
        case ADD_TODO:
            return {
                ...state,
                items: [...state.items, action.payload],
            }
        case TOGGLE_TODO:
            return {
                ...state,
                items: state.items.map((todo) =>
                    todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo,
                ),
            }
        default:
            return state
    }
}

Результат: 3 файли → 1 файл, ~40 рядків → ~25 рядків, набагато читабельніше!


TypeScript Інтеграція

Базова типізація

features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CounterState {
    value: number
    step: number
}

const initialState: CounterState = {
    value: 0,
    step: 1,
}

const counterSlice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        increment: (state) => {
            state.value += state.step
        },

        decrement: (state) => {
            state.value -= state.step
        },

        incrementByAmount: (state, action: PayloadAction<number>) => {
            state.value += action.payload
        },

        setStep: (state, action: PayloadAction<number>) => {
            state.step = action.payload
        },
    },
})

export const { increment, decrement, incrementByAmount, setStep } = counterSlice.actions
export default counterSlice.reducer

Типізація з prepare

reducers: {
  addTodo: {
    reducer: (state, action: PayloadAction<Todo>) => {
      state.items.push(action.payload);
    },
    prepare: (text: string) => {
      return {
        payload: {
          id: nanoid(),
          text,
          completed: false,
        } as Todo,
      };
    },
  },
}

Generic Slice

interface Entity {
    id: string | number
}

function createGenericSlice<T extends Entity>(name: string) {
    return createSlice({
        name,
        initialState: [] as T[],
        reducers: {
            add: (state, action: PayloadAction<T>) => {
                state.push(action.payload)
            },
            remove: (state, action: PayloadAction<string | number>) => {
                return state.filter((item) => item.id !== action.payload)
            },
        },
    })
}

// Використання
interface Todo extends Entity {
    text: string
    completed: boolean
}

const todosSlice = createGenericSlice<Todo>('todos')

Висновок

createSlice — це найпотужніший інструмент Redux Toolkit, який:

✅ Усуває необхідність в константах та action creators
✅ Дозволяє писати мутабельний код безпечно (Immer)
✅ Автоматично генерує все необхідне
✅ Ідеально працює з TypeScript
✅ Спрощує організацію коду

Золоте правило: Один slice = одна логічна feature вашого додатку. Не створюйте гігантські slices і не розбивайте на занадто маленькі.

Наступні кроки

Тепер ми вміємо створювати синхронну логіку. А як щодо асинхронних операцій (API запити, таймери)?

👉 Далі: Асинхронність з createAsyncThunk

Copyright © 2026