Урок 12: Использование нескольких слайсов и комбинирование состояния

До этого момента мы работали с одним-двумя слайсами по отдельности, словно каждый из них был изолированным островом. Но в реальном приложении эти острова должны сообщаться друг с другом через надежные мосты. Пользователь хочет видеть согласованный интерфейс, где изменение в одном месте (например выбор юзера) мгновенно отражается в другом (например список его задач).

Сегодня мы научимся работать с несколькими независимыми слайсами, комбинировать их данные в компонентах и, что самое важное, заставим их взаимодействовать между собой. Мы создадим слайс для фильтров нашего списка задач и свяжем его со слайсом самих задач, а также посмотрим, как один слайс может реагировать на действия, сгенерированные в другом.

Работа с несколькими независимыми слайсами

Идеология Redux и RTK построена на концепции единственного источника истины, на Store. Однако это не означает, что весь наш код должен быть одной большой кучей. Наоборот, лучшей практикой является разбиение логики на небольшие, независимые и отвечающие за свою собственную доменную область модули, это слайсы.

Представьте, что вы строите дом. У вас есть разные подрядчики, один кладет кирпичи, другой проводит электричество, третий сантехнику. Каждый из них, эксперт в своей области и не лезет в работу другого. Так же и наши слайсы. Слайс counter отвечает за числа, слайс todos за массив задач, а слайс users за данные пользователей. Они независимы, потому что изменение счетчика не должно влиять на список задач и наоборот.

На практике эта независимость достигается очень просто. В RTK мы создаем каждый слайс с помощью createSlice отдельно, а затем объединяем их редьюсеры с помощью configureStore.

Настройка store с несколькими слайсами

Давайте вспомним, как выглядит базовая настройка хранилища с двумя слайсами counterSlice и todosSlice.

javascript
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer,
  },
});

В этом коде нет ничего нового, но я хочу акцентировать ваше внимание на структуре получившегося состояния. Redux автоматически комбинирует редьюсеры, создавая объект состояния, ключи которого соответствуют ключам в объекте reducer:

javascript
// Пример структуры состояния (state)
{
  counter: { value: 0 },
  todos: { entities: [], status: 'idle' }
}

Теперь, чтобы получить доступ к значению счетчика, мы обращаемся к state.counter.value, а чтобы к списку задач, к state.todos.entities. Каждый слайс «не знает» о существовании другого, что и делает их по-настоящему независимыми.

Практическая задача 1: Создайте базовую структуру

  • Создайте новый проект или откройте существующий с настройками RTK.

  • Убедитесь, что у вас есть как минимум два рабочих слайса (например, counter и todos), подключенных к хранилищу через configureStore.

  • Воспользуйтесь Redux DevTools, чтобы убедиться, что состояние имеет ожидаемую структуру с двумя независимыми ветками.

Как один компонент может использовать данные из разных слайсов

Вот мы и подошли к самому интересному, к созданию «умных» компонентов, которые могут собирать данные из разных уголков состояния. В жизни React-компонента часто наступает момент, когда ему нужны и данные о задачах и информация о текущем пользователе. С помощью хуков useSelector и useDispatch это делается элементарно.

Хук useSelector можно использовать в одном компоненте столько раз, сколько нужно. Каждый вызов будет подписываться на свою часть состояния.

Компонент панели управления

Допустим, мы хотим создать компонент Dashboard, который показывает одновременно значение счетчика и количество невыполненных задач.

javascript
// Dashboard.js
import React from 'react';
import { useSelector } from 'react-redux';

const Dashboard = () => {
  // Селектор для счетчика
  const count = useSelector((state) => state.counter.value);
  
  // Селектор для задач. Предположим, что в todos хранится массив задач,
  // и у каждой есть поле `completed`.
  const todos = useSelector((state) => state.todos.entities);
  const incompleteTodosCount = todos.filter(todo => !todo.completed).length;

  return (
    <div className="dashboard">
      <h2>Панель управления</h2>
      <p>Текущее значение счетчика: <strong>{count}</strong></p>
      <p>Невыполненных задач: <strong>{incompleteTodosCount}</strong></p>
    </div>
  );
};

export default Dashboard;

В этом компоненте мы дважды используем useSelector. Первый раз мы получаем число из слайса counter, второй массив задач из слайса todos, чтобы вычислить количество невыполненных. Компонент будет перерисовываться при любом изменении в state.counter.value ИЛИ в state.todos.entities. Это мощно, но нужно быть осторожным, чтобы не подписать компонент на слишком большие и часто изменяющиеся части состояния, если это не нужно.

Практическая задача 2: Создайте комбинированный компонент

  • Создайте новый компонент, аналогичный Dashboard.

  • Заставьте его отображать данные как минимум из двух разных слайсов.

  • Протестируйте его: измените состояние в одном слайсе и убедитесь, что компонент перерисовывается корректно.

Как обращаться к состоянию другого слайса внутри extraReducers?

А теперь кульминация нашего урока. До сих пор слайсы жили своей жизнью. Но что, если действие в одном слайсе должно повлиять на состояние в другом? Классический пример: когда мы добавляем новую задачу, мы хотим привязать её к текущему выбранному пользователю. Или когда мы удаляем пользователя, нужно заодно удалить все его задачи.

Для этого в RTK существует механизм extraReducers. Мы уже использовали его для обработки жизненных циклов асинхронного thunk’а в его же слайсе. Но extraReducers может слушать и действия, сгенерированные другими слайсами!

Давайте рассмотрим реальный сценарий. У нас есть слайс users, который хранит список пользователей и текущего выбранного пользователя. И есть слайс todos для задач. Мы хотим, чтобы при добавлении новой задачи, к ней автоматически подставлялся id текущего выбранного пользователя.

Шаг 1: Создаем слайс users с действием выбора пользователя

javascript
// features/users/usersSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  entities: [],
  currentUserId: null, // ID текущего выбранного пользователя
};

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // Экшен для выбора пользователя
    userSelected: (state, action) => {
      state.currentUserId = action.payload;
    },
    // ... другие редьюсеры (добавление, удаление пользователей)
  },
});

export const { userSelected } = usersSlice.actions;
export default usersSlice.reducer;

Шаг 2: Модифицируем слайс todos для использования extraReducers

Теперь нам нужно изменить слайс задач. В экшене todoAdded мы хотим использовать не просто переданный текст, а еще и userId. Но мы не хотим передавать userId вручную из компонента каждый раз. Вместо этого, редьюсер добавления задачи должен «знать» о том, кто сейчас выбран.

Мы можем добиться этого, слушая экшен userSelected из слайса users и сохраняя currentUserId уже внутри слайса todos. Но более прямой и правильный путь в данном случае, это в экшене todoAdded «подглядеть» в состояние слайса users.

Однако, редьюсеры это чистые функции и они не должны вызывать сторонние сервисы или обращаться к глобальным переменным. Вместо этого, мы можем подготовить payload в компоненте, собрав данные из обоих слайсов.

Но есть и более элегантный способ, который часто используется, когда одно действие должно явно повлиять на состояние другого слайса. Давайте создадим отдельный экшен «добавить задачу для выбранного пользователя», который будет объединять логику.

javascript
// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
// Импортируем экшен из другого слайса!
import { userSelected } from '../users/usersSlice';

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    entities: [],
  },
  reducers: {
    // Классическое добавление задачи
    todoAdded: {
      reducer: (state, action) => {
        const { id, text, userId } = action.payload;
        state.entities.push({ id, text, completed: false, userId });
      },
      prepare: (text) => {
        // Обратите внимание, здесь мы НЕ знаем о userId.
        // Он должен быть передан извне.
        return { payload: { id: nanoid(), text } };
      },
    },
  },
  // Используем extraReducers для реакции на экшены из других слайсов
  extraReducers: (builder) => {
    builder
      // Когда пользователь выбирается, мы можем, например,
      // очистить задачи или выполнить другие действия.
      // Но в нашем основном сценарии мы будем использовать другой подход.
      .addCase(userSelected, (state, action) => {
        // Мы можем отреагировать на выбор пользователя.
        // Например, пометить все задачи старого пользователя как "неактивные".
        // Это просто пример реакции.
        console.log(`Пользователь с ID ${action.payload} выбран. TodosSlice знает об этом!`);
      });
  },
});

export const { todoAdded } = todosSlice.actions;
export default todosSlice.reducer;

Комбинирование в компоненте или middleware

Прямое обращение из одного слайса к состоянию другого внутри reducers,  это антипаттерн. Вместо этого есть два основных пути:

  1. Подготовка данных в компоненте. Это самый простой и распространенный способ. Компонент, который диспатчит экшен, сам собирает все необходимые данные из разных слайсов.

    javascript
    // Компонент AddTodoForm.js
    import React, { useState } from 'react';
    import { useDispatch, useSelector } from 'react-redux';
    import { todoAdded } from './todosSlice';
    
    const AddTodoForm = () => {
      const [text, setText] = useState('');
      const dispatch = useDispatch();
      
      // Получаем ID текущего пользователя из слайса users
      const currentUserId = useSelector(state => state.users.currentUserId);
    
      const handleSubmit = (e) => {
        e.preventDefault();
        if (text.trim() && currentUserId) {
          // Диспатчим экшен, передавая в payload и текст и userId
          dispatch(todoAdded({ text, userId: currentUserId }));
          setText('');
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <input value={text} onChange={(e) => setText(e.target.value)} />
          <button type="submit">Добавить задачу для пользователя {currentUserId}</button>
        </form>
      );
    };
    
    export default AddTodoForm;
  2. Использование кастомного action creator или thunk. Если логика становится сложной, можно вынести её в отдельную функцию, например, с помощью createAsyncThunk или просто обычного action creator’а, который диспатчит несколько экшенов или имеет доступ к состоянию через getState.

    javascript
    // features/todos/todosThunks.js
    import { todoAdded } from './todosSlice';
    
    // Это не thunk, а просто Action Creator, который знает о структуре состояния.
    // В реальном проекте для такой простой задачи это может быть избыточно.
    export const addTodoForCurrentUser = (text) => (dispatch, getState) => {
      const state = getState();
      const currentUserId = state.users.currentUserId;
      
      if (currentUserId) {
        dispatch(todoAdded({ text, userId: currentUserId }));
      } else {
        alert('Сначала выберите пользователя!');
      }
    };

    Затем в компоненте мы будем диспатчить не todoAdded, а addTodoForCurrentUser.

Создаем слайс «фильтров» для задач и комбинируем его со слайсом задач

Давайте закрепим все на практике, создав новый слайс filters и связав его со слайсом todos. Мы реализуем фильтрацию задач по статусу: «Все», «Активные», «Выполненные».

Шаг 1: Создаем слайс фильтров

Этот слайс будет очень простым, он будет хранить всего одну строку с текущим фильтром.

javascript
// features/filters/filtersSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  status: 'all', // 'all', 'active', 'completed'
};

const filtersSlice = createSlice({
  name: 'filters',
  initialState,
  reducers: {
    statusFilterChanged: (state, action) => {
      state.status = action.payload;
    },
  },
});

export const { statusFilterChanged } = filtersSlice.actions;
export default filtersSlice.reducer;

Не забудьте добавить его редьюсер в ваш store!

javascript
// store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import filtersReducer from '../features/filters/filtersSlice';

export const store = configureStore({
  reducer: {
    todos: todosReducer,
    filters: filtersReducer,
  },
});

Шаг 2: Создаем компонент для отображения и фильтрации списка задач

Теперь создадим «умный» компонент TodoList, который будет брать задачи из todosSlice, текущий фильтр из filtersSlice, комбинировать их и отображать отфильтрованный список.

javascript
// features/todos/TodoList.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { statusFilterChanged } from '../filters/filtersSlice';

const TodoList = () => {
  const dispatch = useDispatch();

  // 1. Получаем все задачи
  const todos = useSelector(state => state.todos.entities);
  
  // 2. Получаем текущее значение фильтра
  const statusFilter = useSelector(state => state.filters.status);

  // 3. Вычисляем отфильтрованные задачи на основе фильтра
  const filteredTodos = todos.filter(todo => {
    if (statusFilter === 'all') {
      return true;
    }
    if (statusFilter === 'active') {
      return !todo.completed;
    }
    if (statusFilter === 'completed') {
      return todo.completed;
    }
    return true;
  });

  // 4. Обработчик для изменения фильтра
  const onFilterChange = (filter) => {
    dispatch(statusFilterChanged(filter));
  };

  return (
    <div>
      <div>
        <button 
          onClick={() => onFilterChange('all')}
          style={{ fontWeight: statusFilter === 'all' ? 'bold' : 'normal' }}
        >
          Все
        </button>
        <button 
          onClick={() => onFilterChange('active')}
          style={{ fontWeight: statusFilter === 'active' ? 'bold' : 'normal' }}
        >
          Активные
        </button>
        <button 
          onClick={() => onFilterChange('completed')}
          style={{ fontWeight: statusFilter === 'completed' ? 'bold' : 'normal' }}
        >
          Выполненные
        </button>
      </div>
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text} (User: {todo.userId})
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

Этот компонент живая иллюстрация искусства Redux. Он берет данные из двух совершенно независимых слайсов, комбинирует их по нужной бизнес-логике и создает согласованное представление для пользователя. При диспатче экшена statusFilterChanged обновляется состояние в слайсе filters, что вызывает перерисовку TodoList, который заново вычисляет filteredTodos и отображает новый список.

Практическая задача 3: Реализуйте фильтрацию самостоятельно

  • Создайте слайс filters по примеру выше.

  • Интегрируйте его в ваш store.

  • Создайте или модифицируйте компонент TodoList, чтобы он реализовывал фильтрацию по статусу, используя данные из обоих слайсов.

  • Добавьте в интерфейс кнопки или селект для переключения между фильтрами.

Итоги по 12-му уроку

Вы только что освоили один из самых основных навыков в Redux. Это управление сложным, составным состоянием приложения. Вы научились:

  1. Организовывать код в независимые слайсы.

  2. Собирать данные из разных слайсов в одном компоненте.

  3. Заставлять слайсы взаимодействовать друг с другом, в основном через подготовку данных в компонентах и использование extraReducers для реакции на чужие экшены.

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

В следующем уроке мы спустимся на уровень ниже и посмотрим, как работают прослойки (middleware) в Redux, которые позволяют перехватывать и обрабатывать экшены до того, как они попадут в редьюсер. Это откроет перед нами такие возможности, как логирование, обработка асинхронности и многое другое.

Полный курс с уроками по Redux и Redux Toolkit для начинающих.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий