Урок 15: Типизация Redux Toolkit с TypeScript (базовый уровень)

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

Но настал момент сделать наш код по-настоящему профессиональным, надежным и предсказуемым. Мы подошли к тому, чтобы навести в нем строгий порядок с помощью TypeScript. Если вы до сих пор писали на JavaScript, возможно вы уже сталкивались с ошибками вроде undefined is not a function или неправильно переданными пропсами в компоненты. TypeScript это наш главный союзник в борьбе с такими ошибками и сегодня мы научимся применять его силу в связке с Redux Toolkit.

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

В этом уроке мы сфокусируемся на базовой типизации. Мы не будем углубляться в дебри продвинутых Generic-типов, а сделаем самое главное: заставим наш Store, диспатч и селекторы «понимать» типы данных, с которыми они работают. Это уже на 90% решит большинство потенциальных проблем и невероятно повысит уверенность в вашем коде.

Зачем вообще типизировать Redux?

Redux и так требует написания довольно большого количества шаблонного кода. Зачем же добавлять к этому еще и типы? Ответ прост: предсказуемость и надежность.

Представьте, что вы работаете в команде. Ваш коллега разрабатывает слайс для управления пользователями, а вы компонент, который должен отображать список этих пользователей. Без TypeScript вам придется либо постоянно заглядывать в код слайса, чтобы вспомнить структуру объекта пользователя, либо надеяться на свою память и документацию (которая, как известно, быстро устаревает). Одна опечатка в названии свойства и в лучшем случае вы получите undefined в интерфейсе, а в худшем падение всего приложения.

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

Основные преимущества типизации Redux:

  1. Автодополнение и IntelliSense. Ваша IDE (VSCode, WebStorm) будет точно знать, что находится в состоянии и будет предлагать вам доступные свойства.

  2. Обнаружение ошибок на этапе компиляции. Вы узнаете об ошибке не в рантайме, когда пользователь уже нажал на кнопку, а сразу при написании кода.

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

  4.  Безопасный рефакторинг. Если вы решите изменить структуру состояния, TypeScript укажет на все места в коде, которые нужно поправить.

Redux Toolkit изначально написан на TypeScript и предоставляет отличные типы «из коробки». Наша задача правильно их использовать.

Настраиваем типизированный Store

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

Вспомним, как мы создаем store с помощью configureStore:

javascript
// store.ts (JavaScript-версия)
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './features/counter/counterSlice'

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

В TypeScript-версии нам нужно сделать немного больше, явно экспортировать типы, которые представляет собой наше состояние store и наш метод dispatch.

typescript
// store.ts (TypeScript-версия)
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './features/counter/counterSlice'

// Создаем store, как и раньше
export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
})

// Экспортируем центральные типы!
// Получаем тип нашего состояния (RootState)
export type RootState = ReturnType<typeof store.getState>
// Получаем тип нашего Dispatch метода (AppDispatch)
export type AppDispatch = typeof store.dispatch

Давайте разберем эти два новых типа:

  • RootState. Этот тип динамически вычисляется на основе того, что возвращает функция store.getState(). Мы используем утилиту TypeScript ReturnType, которая извлекает возвращаемый тип функции. Если вы добавите новый слайс в reducer, тип RootState автоматически расширится и будет включать состояние этого нового слайса. Вам не нужно будет обновлять его вручную.

  • AppDispatch. Этот тип просто берется из самого свойства store.dispatch. Поскольку мы используем Redux Toolkit, в dispatch уже встроена поддержка thunk-ов и других middleware. Использование этого типа гарантирует, что при диспатче асинхронных операций с createAsyncThunk у нас не будет ошибок типизации.

Теперь мы можем использовать их для создания типизированных версий хуков useDispatch и useSelector.

Создаем типизированные хуки useDispatch и useSelector

Хуки useDispatch и useSelector из react-redux это наше окно в мир Redux внутри React-компонентов. По умолчанию они «не знают» о структуре нашего состояния. Мы должны им «рассказать» о ней, используя типы RootState и AppDispatch, которые мы только что создали.

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

Создадим для этого отдельный файл, например hooks.ts в папке нашего store.

typescript
// store/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './store' // Импортируем наши типы

// Создаем типизированную версию useDispatch
export const useAppDispatch = () => useDispatch<AppDispatch>()
// Создаем типизированную версию useSelector
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

Что здесь происходит?

  1. useAppDispatch. Это простая функция, которая вызывает стандартный useDispatch, но мы явно указываем, что он работает с нашим типом AppDispatch. Это нужно для корректной работы с thunk-ами.

  2. useAppSelector. Здесь мы используем специальный тип TypedUseSelectorHook из react-redux, передавая в него наш RootState. Это «обучает» хук useSelector понимать структуру всего нашего состояния.

Теперь во всех наших компонентах мы будем импортировать не стандартные useDispatch и useSelector, а наши кастомные useAppDispatch и useAppSelector. Это даст нам всю мощь TypeScript-подсказок и проверок.

Типизируем слайс счетчика

Давайте вернемся слайсу счетчика и приведем его к TypeScript-виду. Вот как он выглядел на JavaScript:

javascript
// counterSlice.js (JavaScript)
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
  value: 0,
}

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    incremented: (state) => {
      state.value += 1
    },
    decremented: (state) => {
      state.value -= 1
    },
    incrementedByAmount: (state, action) => {
      state.value += action.payload
    },
  },
})

export const { incremented, decremented, incrementedByAmount } = counterSlice.actions
export default counterSlice.reducer

Теперь напишем его на TypeScript. Нам нужно явно описать тип начального состояния и типы payload для наших экшенов.

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

// 1. Определяем тип для нашего состояния
interface CounterState {
  value: number
}

// 2. Определяем начальное состояние с использованием этого типа
const initialState: CounterState = {
  value: 0,
}

export const counterSlice = createSlice({
  name: 'counter',
  initialState, // TypeScript автоматически поймет тип здесь
  reducers: {
    incremented: (state) => {
      state.value += 1
    },
    decremented: (state) => {
      state.value -= 1
    },
    // 3. Используем PayloadAction для типизации action.payload
    incrementedByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload // Теперь action.payload - это гарантированно number
    },
  },
})

// Экспортируем экшены и редьюсер как обычно
export const { incremented, decremented, incrementedByAmount } = counterSlice.actions
export default counterSlice.reducer

Ключевые изменения:

  1. Интерфейс CounterState. Мы явно объявили, что состояние нашего счетчика это объект с одним полем value типа number.

  2. Типизация initialState. Мы сказали TypeScript, что initialState должна соответствовать типу CounterState.

  3. PayloadAction<number>. Это, возможно, самое важное изменение. Мы импортировали тип PayloadAction из Redux Toolkit и использовали его для типизации второго аргумента — action. Указав <number>, мы сообщаем TypeScript, что в свойстве payload этого экшена должно находиться число. Теперь, если мы попытаемся диспатчить incrementedByAmount('пять'), TypeScript тут же укажет нам на ошибку.

Создаем типизированный компонент счетчика

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

tsx
// features/counter/Counter.tsx
import React, { useState } from 'react';
// Импортируем НЕ стандартные хуки, а наши типизированные!
import { useAppSelector, useAppDispatch } from '../../store/hooks';
// Импортируем наши экшены
import { incremented, decremented, incrementedByAmount } from './counterSlice';

export const Counter: React.FC = () => {
  // 1. Типизированное получение состояния
  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

  // Локальное состояние для инпута (оставляем его, т.к. оно не нужно в глобальном store)
  const [incrementAmount, setIncrementAmount] = useState<string>('2'); // Тип для инпута - string

  // 2. Типизированный диспатч экшенов
  const handleIncrement = () => dispatch(incremented());
  const handleDecrement = () => dispatch(decremented());

  const handleIncrementByAmount = () => {
    // Парсим строку в число. TypeScript поможет убедиться, что мы работаем с number.
    const value = parseInt(incrementAmount, 10) || 0;
    dispatch(incrementedByAmount(value)); // TypeScript проверит, что value - number!
  };

  return (
    <div>
      <h2>Типизированный Счетчик: {count}</h2>
      <div>
        <button onClick={handleIncrement}>+</button>
        <span>{count}</span>
        <button onClick={handleDecrement}>-</button>
      </div>
      <div>
        <input
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
          type="number"
        />
        <button onClick={handleIncrementByAmount}>
          Прибавить указанное значение
        </button>
      </div>
    </div>
  );
};

Что мы получили благодаря TypeScript:

  • В строке state.counter.value ваш редактор кода будет предоставлять автодополнение. Он точно знает, что после state.counter есть только свойство value.

  • Если вы по ошибке напишете state.counter.count или state.count.value, TypeScript сразу подчеркнет это красным и скажет, что такого свойства не существует.

  • При вызове dispatch(incrementedByAmount('строка')) TypeScript выдаст ошибку, так как ожидается число.

Практическая задача для закрепления

Чтобы убедиться, что вы поняли материал, выполните следующее задание.

Задача: У вас есть слайс для управления списком дел (todos). Его начальное состояние на JavaScript выглядело так:

javascript
const initialState = {
  items: [
    { id: 1, text: 'Изучить Redux', completed: true },
    { id: 2, text: 'Изучить TypeScript', completed: false },
  ],
  filter: 'all', // 'all', 'completed', 'active'
}
  1. Создайте интерфейсы для:

    • Отдельной задачи (TodoItem), которая должна иметь id (число), text (строка) и completed (булево значение).

    • Состояния слайса (TodosState), которое включает массив TodoItem и строку filter.

  2. Типизируйте начальное состояние (initialState).

  3. В слайсе есть редьюсер toggleTodo, который принимает action с payload в виде id задачи (число). Типизируйте этот экшен с помощью PayloadAction.

  4. Создайте типизированный компонент, который использует useAppSelector для отображения списка задач и useAppDispatch для диспатча экшена toggleTodo.

Пример решения (типизация слайса):

typescript
// features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// 1. Определяем интерфейсы
interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

interface TodosState {
  items: TodoItem[];
  filter: 'all' | 'completed' | 'active';
}

// 2. Типизируем начальное состояние
const initialState: TodosState = {
  items: [
    { id: 1, text: 'Изучить Redux', completed: true },
    { id: 2, text: 'Изучить TypeScript', completed: false },
  ],
  filter: 'all',
};

export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    toggleTodo: (state, action: PayloadAction<number>) => { // 3. Типизируем payload
      const todo = state.items.find(todo => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    // ... другие редьюсеры
  },
});

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

Итоги 15-го урока

Вы научились:

  • Создавать типизированный store и экспортировать фундаментальные типы RootState и AppDispatch.

  • Создавать и использовать типизированные версии хуков useDispatch и useSelector.

  • Правильно типизировать слайс, его состояние и экшены с помощью PayloadAction.

  • Собирать все это вместе в типобезопасном React-компоненте.

Это база, которая покроет 90% ваших повседневных задач с Redux Toolkit и TypeScript. Вы сразу почувствуете, насколько меньше стало «глупых» ошибок и насколько быстрее вы пишете код благодаря автодополнению.

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

Полный курс с уроками по Redux и Redux Toolkit для начинающих: https://max-gabov.ru/redux-dlya-nachinaushih

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

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

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