Урок 16: Продвинутая типизация слайсов и `createAsyncThunk`

Я продолжаю наш курс по Redux и Redux Toolkit. Мы с вами уже научились создавать слайсы, работать с асинхронными запросами и даже затронули базовую типизацию. Но сейчас мы подходим к одному из самых основных и сложных рубежей продвинутой типизации.

TypeScript это не просто мода, это инструмент для построения надежных, предсказуемых и легко поддерживаемых приложений. Когда ваше состояние, экшены и редьюсеры полностью типизированы, вы ловите огромное количество ошибок еще на этапе написания кода, а ваш редактор кода превращается в преданного помощника, который подсказывает буквально все.

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

В моем блоге вы также найдете полный цикл уроков по TypeScript для начинающих.

Полная типизация слайса

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

Типизация initialState

Первое, с чего всегда стоит начинать, это определение типа для начального состояния. Это основа, на которую будут опираться все наши редьюсеры.

Раньше мы просто писали:

javascript
const initialState = {
  items: [],
  loading: false,
  error: null,
};

Теперь мы должны явно описать, какому типу соответствует этот объект.

Практическая задача: Давайте полностью типизируем слайс для управления списком задач (todos).

typescript
// features/todos/todosSlice.ts

// 1. Определяем интерфейс для отдельной задачи
interface ITodo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: number; // timestamp
}

// 2. Определяем интерфейс для всего состояния слайса
interface TodosState {
  items: ITodo[];
  loading: 'idle' | 'pending' | 'succeeded' | 'failed'; // Часто используют строковые литералы для статусов
  error: string | null;
}

// 3. Создаем начальное состояние с явным указанием типа
const initialState: TodosState = {
  items: [],
  loading: 'idle',
  error: null,
};

Обрати внимание на несколько ключевых моментов:

  1. ITodo. Мы детально описываем, из чего состоит одна задача. Это не просто объект с text, а структура с ID, статусом выполнения и временем создания.

  2. TodosState. Мы описываем всю структуру состояния слайса. Массив items теперь строго типизирован, в него можно положить только объекты, соответствующие ITodo. Поле loading мы сделали не просто булевым, а более информативным, используя объединение типов. Это отличная практика для отслеживания точного статуса запроса.

  3. initialState: TodosState. Мы явно указываем тип для начального состояния. Это не только хороший тон, но и защита от случайного добавления полей, не описанных в интерфейсе.

Типизация createSlice

Самое интересное начинается при использовании createSlice. Redux Toolkit написан на TypeScript и прекрасно работает с типами, часто выводя их автоматически.

Самое главное, что нам нужно сделать, это типизировать state и action в каждом редьюсере.

typescript
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState, // TypeScript теперь знает, что initialState это TodosState
  reducers: {
    // 1. Типизация простого экшена (без payload)
    clearTodos(state) {
      // state уже имеет тип TodosState благодаря initialState
      state.items = [];
    },
    // 2. Тизизация экшена с payload
    addTodo(state, action: PayloadAction<string>) {
      // PayloadAction<ТипНашегоПейлоада> - это дженерик тип из RTK
      // Здесь мы говорим, что в action.payload придет строка (title задачи)
      const newTodo: ITodo = {
        id: Date.now().toString(), // Простой способ генерации ID
        title: action.payload, // TypeScript знает, что это string!
        completed: false,
        createdAt: Date.now(),
      };
      state.items.push(newTodo);
    },
    // 3. Типизация экшена с более сложным payload
    toggleTodo(state, action: PayloadAction<string>) {
      // В этот раз payload - это ID задачи (строка)
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    updateTodoTitle(state, action: PayloadAction<{ id: string; newTitle: string }>) {
      // А здесь payload - это объект с полями id и newTitle
      const todo = state.items.find(item => item.id === action.payload.id);
      if (todo) {
        todo.title = action.payload.newTitle;
      }
    },
  },
});

// Redux Toolkit сгенерирует типизированные экшены!
// Например, тип для addTodo будет: { type: 'todos/addTodo', payload: string }
export const { clearTodos, addTodo, toggleTodo, updateTodoTitle } = todosSlice.actions;
export default todosSlice.reducer;

Вот что здесь произошло:

  • state в каждом редьюсере автоматически получил тип TodosState благодаря тому, что мы передали типизированный initialState.

  • PayloadAction<T> это наш главный инструмент. Этот дженерик-тип ожидает указания типа для action.payload. Без этого TypeScript считал бы payload типом any и мы потеряли бы всю пользу от типизации.

Теперь, когда мы будем диспатчить эти экшены в компонентах, TypeScript будет строго следить за тем, что мы передаем в payload. Попробуешь передать в addTodo число? Получишь ошибку на этапе компиляции.

Типизация createAsyncThunk

Перейдем к более сложной, но не менее важной теме, это типизации асинхронных операций. createAsyncThunk это сердце асинхронности в RTK и его правильная типизация делает работу с API предсказуемой и безопасной.

createAsyncThunk принимает до трех дженерик-аргументов:

  1. Returned тип значения, которое возвращает (резолвится) thunk, если запрос успешен. То, что попадет в action.payload в случае fulfilled.

  2. ThunkArg тип аргумента, который передается в thunk-функцию при вызове. Например, ID пользователя или объект с данными для создания.

  3. ThunkApiConfig (опционально) конфигурация для thunk API, например, типы для state (getState) и dispatch. Чаще всего нам нужно определить здесь тип для rejectValue.

Типизируем асинхронный запрос на получение пользователей

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

typescript
// features/users/usersSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

// 1. Определяем интерфейс для пользователя
interface IUser {
  id: number;
  name: string;
  email: string;
  username: string;
}

// 2. Определяем состояние для слайса пользователей
interface UsersState {
  list: IUser[];
  loading: 'idle' | 'pending' | 'succeeded' | 'failed';
  error: string | null;
}

const initialState: UsersState = {
  list: [],
  loading: 'idle',
  error: null,
};

// 3. Создаем типизированный thunk!
//    createAsyncThunk<Returned, ThunkArg, { rejectValue: типОшибки }>
export const fetchUsers = createAsyncThunk<
  IUser[], // Returned - успешный запрос вернет массив пользователей
  void, // ThunkArg - аргументов у нас нет, поэтому void
  { rejectValue: string } // ThunkApiConfig - мы хотим типизировать rejectValue как строку
>('users/fetchUsers', async (_, thunkAPI) => {
  // _ - это наш ThunkArg (void, поэтому игнорируем)
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!response.ok) {
      // Если ответ не успешен, выбрасываем ошибку
      throw new Error('Ошибка при загрузке пользователей');
    }
    const data: IUser[] = await response.json();
    return data; // Возвращаем данные -> попадут в action.payload
  } catch (error) {
    // Обрабатываем ошибку и возвращаем ее через rejectWithValue
    return thunkAPI.rejectWithValue((error as Error).message);
    // Теперь TypeScript знает, что в случае ошибки в action.payload будет строка
  }
});

Разберем по косточкам:

  • IUser[]. Говорим TypeScript, что при успешном запросе мы получим массив объектов типа IUser.

  • void. Наш thunk не принимает аргументов, поэтому указываем void.

  • { rejectValue: string }. Мы говорим, что в случае отклоненного (rejected) запроса, в action.payload будет находиться строка с сообщением об ошибке. Это достигается с помощью метода thunkAPI.rejectWithValue().

Обработка типизированного thunk в extraReducer

Теперь нам нужно обработать все три состояния нашего thunk’а в слайсе.

typescript
const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // ... какие-то синхронные редьюсеры ...
  },
  extraReducers: (builder) => {
    builder
      // pending - запрос начался
      .addCase(fetchUsers.pending, (state) => {
        state.loading = 'pending';
        state.error = null; // Чистим старую ошибку
      })
      // fulfilled - запрос успешно завершен
      .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<IUser[]>) => {
        // TypeScript знает, что action.payload это IUser[]!
        state.loading = 'succeeded';
        state.list = action.payload;
      })
      // rejected - запрос завершился ошибкой
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = 'failed';
        // Вот здесь ключевой момент типизации ошибок!
        // action.payload может быть undefined, если ошибка была не обработана через rejectWithValue
        // Но мы обработали, поэтому используем тип из ThunkApiConfig
        if (action.payload) {
          // Ошибка обработана через rejectWithValue
          state.error = action.payload;
        } else {
          // Какая-то другая ошибка (например, сетевая)
          state.error = action.error.message || 'Неизвестная ошибка';
        }
      });
  },
});

export default usersSlice.reducer;

Самое важное здесь, это обработка ошибки в rejected.

  • action.payload в случае rejected имеет тип string | undefinedstring, потому что мы так определили в { rejectValue: string }, а undefined потому что, теоретически, ошибка могла произойти не в нашем коде, а где-то еще и тогда rejectWithValue не был бы вызван.

  • Мы делаем простую проверку if (action.payload) и в зависимости от нее устанавливаем ошибку. Это делает обработку ошибок надежной и типобезопасной.

Сводим все вместе

Задача такая. Расширь слайс задач (todos), добавив асинхронную операцию для сохранения задачи на мнимый сервер. Thunk должен принимать объект задачи (без id, так как сервер его сгенерирует) и возвращать созданную задачу (с id). Не забудь про обработку всех состояний и типизацию ошибок.

Решение:

typescript
// features/todos/todosAsyncThunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit';

// Описываем, что мы отправляем на сервер (без id)
type CreateTodoDto = Omit<ITodo, 'id'>;

// Создаем thunk для добавления задачи на "сервер"
export const addTodoAsync = createAsyncThunk<
  ITodo, // Сервер вернет готовую задачу с ID
  CreateTodoDto, // Мы отправляем объект без ID
  { rejectValue: string }
>('todos/addTodoAsync', async (newTodoData, thunkAPI) => {
  try {
    // Имитация запроса к API
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      body: JSON.stringify({
        title: newTodoData.title,
        body: newTodoData.title, // для примера
        userId: 1,
      }),
      headers: {
        'Content-type': 'application/json; charset=UTF-8',
      },
    });

    if (!response.ok) {
      throw new Error('Не удалось создать задачу');
    }

    const data = await response.json();

    // Имитируем ответ сервера, создавая объект ITodo
    // В реальном приложении сервер вернет данные задачи
    const createdTodo: ITodo = {
      id: data.id.toString(), // Предположим, сервер вернул ID
      title: newTodoData.title,
      completed: false,
      createdAt: Date.now(),
    };

    return createdTodo;
  } catch (error) {
    return thunkAPI.rejectWithValue((error as Error).message);
  }
});

Теперь добавим обработку этого thunk’а в наш слайс todosSlice:

typescript
// В файле todosSlice.ts, импортируем addTodoAsync и добавляем extraReducers

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    // ... наши старые синхронные редьюсеры (addTodo, toggleTodo и т.д.) ...
  },
  extraReducers: (builder) => {
    builder
      .addCase(addTodoAsync.pending, (state) => {
        // Можно добавить индикатор загрузки для добавления, если нужно
        // state.loading = 'pending'; // Но осторожно, это может конфликтовать с другими операциями!
      })
      .addCase(addTodoAsync.fulfilled, (state, action: PayloadAction<ITodo>) => {
        // Сервер вернул созданную задачу -> добавляем ее в состояние
        state.items.push(action.payload);
      })
      .addCase(addTodoAsync.rejected, (state, action) => {
        // Обрабатываем ошибку, например, показываем уведомление
        console.error('Ошибка при добавлении задачи:', action.payload);
        // Или можно записать ошибку в отдельное поле состояния
        // state.addTodoError = action.payload;
      });
      // .addCase(...) // здесь же можно обрабатывать другие thunk'и
  },
});

Итоги и выводы 16-го урока

Мы с прошли один из самых сложных, но и самых полезных уроков. Давай резюмируем, что мы сегодня сделали:

  1. Типизировали слайс полностью. Мы начали с интерфейсов для initialState и пошагово типизировали каждый редьюсер, используя PayloadAction<T>. Это гарантирует, что данные в нашем состоянии всегда соответствуют ожидаемой структуре.

  2. Познакомились с createAsyncThunk дженериками. Мы разобрали три ключевых аргумента типизации: ReturnedThunkArg и ThunkApiConfig (в частности, rejectValue).

  3. Научились обрабатывать ошибки типобезопасно. Использование rejectWithValue и последующая проверка action.payload в rejected кейсе. Это профессиональный подход к обработке асинхронных ошибок в Redux.

  4. Выполнили практическое задание. Мы создали полноценный типизированный асинхронный thunk для добавления задачи и интегрировали его в слайс.

Теперь код стал не просто мощным благодаря Redux Toolkit, но и невероятно надежным благодаря TypeScript. Сочетание предсказуемости Flux-архитектуры и статической типизации, это тот фундамент, на котором строятся современные, масштабируемые фронтенд-приложения.

Типизация это навык, который оттачивается практикой. Возвращайся к этому уроку, перечитывай код, пробуй типизировать свои собственные слайсы и thunk’и. Вскоре это станет твоей второй натурой.

В следующем уроке мы посмотрим, как тестировать React-компоненты, которые используют все это богатство, Redux, RTK Query и TypeScript.

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

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

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

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