Redux Toolkit як засіб ефективної розробки

Redux

В даний час розробка левової частки веб-застосунків, заснованих на фреймворку React, ведеться з використанням бібліотеки Redux. Ця бібліотека є найпопулярнішою реалізацією FLUX-архітектури і, незважаючи на низку очевидних переваг, має дуже суттєві недоліки, такі як:

  • складність і “багатослівність” рекомендованих патернів для написання та організації коду, що тягне за собою велику кількість бойлерплейту;
  • відсутність вбудованих засобів управління асинхронною поведінкою та побічними ефектами, що призводить до необхідності вибору відповідного інструменту з безлічі аддонів, написаних сторонніми розробниками.

Для усунення цих недоліків розробники Redux представили бібліотеку Redux Toolkit. Цей інструмент є набір практичних рішень та методів, призначених для спрощення розробки додатків з використанням Redux. Розробники цієї бібліотеки мали на меті спростити типові випадки використання Redux. Цей інструмент не є універсальним рішенням у кожному з можливих випадків використання Redux, але дозволяє спростити код, який потрібно написати розробнику.

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

Коротко про бібліотеку

Коротка інформація про Redux Toolkit:

  • до релізу бібліотека називалася redux-starter-kit;
  • реліз відбувся наприкінці жовтня 2019 року;
  • Бібліотека офіційно підтримується розробниками Redux.

Згідно з заявою розробників Redux Toolkit виконує такі функції:

  • допомагає швидко розпочати використання Redux;
  • спрощує роботу з типовими завданнями та кодом Redux;
  • дозволяє використовувати найкращі практики Redux за замовчуванням;
  • пропонує рішення, які зменшують недовіру до бойлерплейтів.

Redux Toolkit надає набір як спеціально розроблених, так і додає ряд інструментів, що добре себе зарекомендували, які зазвичай використовуються спільно з Redux. Такий підхід дозволяє розробнику вирішити, як і які інструменти використовувати у своєму додатку. Під час цієї статті ми будемо відзначати які запозичення використовує дана бібліотека. Більш повну інформацію та залежності Redux Toolkit можна отримати з опису пакету @reduxjs/toolkit.

Найбільш значущими функціями, які надає бібліотека Redux Toolkit є:

  • configureStore – функція, призначена спростити процес створення та налаштування сховища;
  • createReducer — функція, що допомагає лаконічно та зрозуміло описати та створити ред’юсер;
  • createAction – повертає функцію творця дії для заданого рядка типу дії;
  • createSlice – поєднує в собі функціонал createAction і createReducer;
  • createSelector – функція з бібліотеки Reselect, переекспортована для простоти використання.

Також варто відзначити, що Redux Toolkit повністю інтегрований з TypeScript. Докладнішу інформацію про це можна отримати з розділу Usage With TypeScript офіційної документації.

Застосування

Розглянемо використання бібліотеки Redux Toolkit на прикладі фрагмента програми, що реально використовується React Redux.

Примітка. Далі у статті наводиться вихідний код як без використання Redux Toolkit, так і з використанням, що дозволить краще оцінити позитивні та негативні сторони використання цієї бібліотеки.

Завдання

В одному з наших внутрішніх додатків виникла потреба додавати, редагувати і відображати інформацію про релізи програмних продуктів, які ми випускаємо. Для кожної з цих дій були розроблені окремі функції API, результати виконання яких потрібно додавати в Redux store. Як засіб управління асинхронною поведінкою та побічними ефектами будемо використовувати Thunk.

Створення сховища

Початковий варіант вихідного коду, що здійснює створення сховища, виглядав наступним чином:

import {
  createStore, applyMiddleware, combineReducers, compose,
} from 'redux';
import thunk from 'redux-thunk';
import * as reducers from './reducers';

const ext = window.__REDUX_DEVTOOLS_EXTENSION__;
const devtoolMiddleware = 
  ext && process.env.NODE_ENV === 'development' ? ext() : f => f;

const store = createStore(
 combineReducers({
   ...reducers,
 }),
 compose(
   applyMiddleware(thunk),
   devtoolMiddleware
 )
);

Якщо уважно поглянути на наведений код, можна побачити досить довгу послідовність дій, яку необхідно зробити, щоб сховище було повністю налаштовано. Redux Toolkit містить інструмент, покликаний спростити цю процедуру, а саме: функцію configureStore.

Функція configureStore

Даний інструмент дозволяє автоматично комбінувати редьюсери, додати мідлвари Redux (за замовчуванням включає redux-thunk), а також використовувати розширення Redux DevTools. Як вхідні параметри функція configureStore приймає об’єкт з такими властивостями:

  • reducer — набір ред’юсерів користувача,
  • middleware – опціональний параметр, що задає масив мідлварів, призначених для підключення до сховища,
  • devTools — параметр логічного типу, що дозволяє увімкнути встановлене у браузер розширення Redux DevTools (значення за промовчанням — true),
  • preloadedState – опціональний параметр, що задає початковий стан сховища,
  • enhancers – опціональний параметр, що задає набір підсилювачів.

Для отримання найбільш популярного списку мідлвар можна скористатися спеціальною функцією getDefaultMiddleware, що також входить до складу Redux Toolkit. Ця функція повертає масив із включеними за замовчуванням до бібліотеки Redux Toolkit мідлварами. Перелік цих мідлвар відрізняється залежно від того, в якому режимі виконується ваш код. У режимі production масив складається тільки з одного елемента – thunk. У режимі development на момент написання статті список поповнюється такими мідлварами:

  • serializableStateInvariant — інструмент, спеціально розроблений для використання в Redux Toolkit і призначений для перевірки дерева станів на наявність несеріалізованих значень, таких як функції, Promise, Symbol та інші значення, що не є простими JS-даними;
  • immutableStateInvariant – мідлвар з пакету redux-immutable-state-invariant , призначений для виявлення мутацій даних, що містяться в сховищі.

Для завдання списку мідлварів, що повертається, функція getDefaultMidlleware приймає об’єкт, що визначає перелік включених мідлварів і налаштування для кожного з них. Докладніше з цією інформацією можна ознайомитись у відповідному розділі офіційної документації.

Тепер перепишемо ділянку коду, відповідальний створення сховища, скориставшись описаними вище інструментами. В результаті отримаємо наступне:

import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import * as reducers from './reducers';

const middleware = getDefaultMiddleware({
  immutableCheck: false,
  serializableCheck: false,
  thunk: true,
});

export const store = configureStore({
 reducer: { ...reducers },
 middleware,
 devTools: process.env.NODE_ENV !== 'production',
});

На прикладі даної ділянки коду добре видно, що функція configureStore вирішує такі проблеми:

  • необхідність комбінувати ред’юсери, автоматично викликаючи combineReducers,
  • необхідність комбінувати мідлвари, автоматично викликаючи applyMiddleware.

А також дозволяє зручніше включити розширення Redux DevTools, використовуючи функцію composeWithDevTools з пакету redux-devtools-extension. Все сказане вище свідчить про те, що використання цієї функції дозволяє зробити код більш компактним і зрозумілим.

На цьому створення та налаштування сховища завершено. Передаємо його у провайдер і переходимо далі.

Дії, творці дій та редьюсер

Тепер розглянемо можливості Redux Toolkit щодо розробки дій, творців дій і редьюсера. Початковий код без використання Redux Toolkit був організований у вигляді файлів actions.js і reducers.js. Вміст файлу actions.js виглядав так:

import * as productReleasesService from '../../services/productReleases';

export const PRODUCT_RELEASES_FETCHING = 'PRODUCT_RELEASES_FETCHING';
export const PRODUCT_RELEASES_FETCHED = 'PRODUCT_RELEASES_FETCHED';
export const PRODUCT_RELEASES_FETCHING_ERROR =
  'PRODUCT_RELEASES_FETCHING_ERROR';

…

export const PRODUCT_RELEASE_UPDATING = 'PRODUCT_RELEASE_UPDATING';
export const PRODUCT_RELEASE_UPDATED = 'PRODUCT_RELEASE_UPDATED';
export const PRODUCT_RELEASE_CREATING_UPDATING_ERROR =
  'PRODUCT_RELEASE_CREATING_UPDATING_ERROR';

function productReleasesFetching() {
  return {
    type: PRODUCT_RELEASES_FETCHING
  };
}

function productReleasesFetched(productReleases) {
  return {
    type: PRODUCT_RELEASES_FETCHED,
    productReleases
  };
}

function productReleasesFetchingError(error) {
  return {
    type: PRODUCT_RELEASES_FETCHING_ERROR,
    error
  }
}

…

export function fetchProductReleases() {
  return dispatch => {
    dispatch(productReleasesFetching());
    return productReleasesService.getProductReleases().then(
      productReleases => dispatch(productReleasesFetched(productReleases))
    ).catch(error => {
      error.clientMessage = "Can't get product releases";
      dispatch(productReleasesFetchingError(error))
    });
  }
}

…

export function updateProductRelease(
  id, productName, productVersion, releaseDate
) {
  return dispatch => {
    dispatch(productReleaseUpdating());
    return productReleasesService.updateProductRelease(
      id, productName, productVersion, releaseDate
    ).then(
      productRelease => dispatch(productReleaseUpdated(productRelease))
    ).catch(error => {
      error.clientMessage = "Can't update product releases";
      dispatch(productReleaseCreatingUpdatingError(error))
    });
  }
}

Вміст файлу reducers.js до використання Redux Toolkit:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 updatingState: 'none',
 error: null,
};

export default function reducer(state = initialState, action = {}) {
 switch (action.type) {
   case productReleases.PRODUCT_RELEASES_FETCHING:
     return {
       ...state,
       fetchingState: 'requesting',
       error: null,
     };
   case productReleases.PRODUCT_RELEASES_FETCHED:
     return {
       ...state,
       productReleases: action.productReleases,
       fetchingState: 'success',
     };
   case productReleases.PRODUCT_RELEASES_FETCHING_ERROR:
     return {
       ...state,
       fetchingState: 'failed',
       error: action.error
     };

…

   case productReleases.PRODUCT_RELEASE_UPDATING:
     return {
       ...state,
       updatingState: 'requesting',
       error: null,
     };
   case productReleases.PRODUCT_RELEASE_UPDATED:
     return {
       ...state,
       updatingState: 'success',
       productReleases: state.productReleases.map(productRelease => {
         if (productRelease.id === action.productRelease.id)
           return action.productRelease;
         return productRelease;
       })
     };
   case productReleases.PRODUCT_RELEASE_UPDATING_ERROR:
     return {
       ...state,
       updatingState: 'failed',
       error: action.error
     };
   default:
     return state;
 }
}

Як ми можемо бачити, саме тут міститься більшість бойлерплейта: константи типів дій, творці дій, знову константи, але вже у коді редьюсера на написання всього цього коду доводиться витрачати час. Частково цього бойлерплейту можна позбутися, якщо скористатися функціями createAction і createReducer, які також входять до складу Redux Toolkit.

Функція createAction

У наведеній ділянці коду використовується стандартний спосіб визначення дії Redux: спочатку окремо оголошується константа, що визначає тип дії, після чого – функція творця дії цього типу. Функція createAction об’єднує ці об’яви в одне. На вхід вона приймає тип дії та повертає творця дії для цього типу. Автор дії може бути викликаний або без аргументів, або з деяким аргументом (корисне навантаження), значення якого буде поміщено в полі payload, створеного дії. Крім того, автор дії перевизначає функцію toString(), так що тип дії стає його рядковим уявленням.

У деяких випадках може знадобитися написати додаткову логіку для налаштування значення корисного навантаження, наприклад, прийняти кілька параметрів для творця дії, створити випадковий ідентифікатор або отримати поточну мітку. Для цього createAction приймає необов’язковий другий аргумент – функцію, яка використовуватиметься для оновлення значення корисного навантаження. Докладніше про цей параметр можна ознайомитись в офіційній документації.
Використовуючи функцію createAction, отримаємо наступний код:

export const productReleasesFetching =
  createAction('PRODUCT_RELEASES_FETCHING');
export const productReleasesFetched =
  createAction('PRODUCT_RELEASES_FETCHED');
export const productReleasesFetchingError =
  createAction('PRODUCT_RELEASES_FETCHING_ERROR');

…

export function fetchProductReleases() {
  return dispatch => {
    dispatch(productReleasesFetching());
    return productReleasesService.getProductReleases().then(
      productReleases => dispatch(productReleasesFetched({ productReleases }))
    ).catch(error => {
      error.clientMessage = "Can't get product releases";
      dispatch(productReleasesFetchingError({ error }))
    });
  }
}
...

Функція createReducer

Тепер розглянемо ред’юсер. Як і наш приклад, редьюсери часто реалізуються з допомогою оператора switch, з одним регістром кожному за обробленого типу дії. Цей підхід працює добре, але не позбавлений бойлерплейта і схильний до помилок. Наприклад, легко забути описати випадок default або встановити початковий стан. Функція createReducer спрощує створення функцій редьюсера, визначаючи їх як таблиці пошуку функцій обробки кожного типу дії. Вона також дозволяє суттєво спростити логіку іммутабельного поновлення, написавши код у “мутабельному” стилі всередині ред’юсерів.

“Мутабельний” стиль обробки подій доступний завдяки використанню бібліотеки Immer. Функція обробник може або “мутувати” переданий state для зміни властивостей, або повертати новий state, як при роботі в іммутабельному стилі, але завдяки Immer реальна мутація об’єкта не здійснюється. Перший варіант набагато простіше для роботи та сприйняття, особливо при зміні об’єкта з глибокою вкладеністю.

Будьте уважні: повернення нового об’єкта із функції перекриває “мутабельні” зміни. Одночасне застосування обох методів поновлення стану не спрацює.

Як вхідні параметри функція createReducer приймає такі аргументи:

  • початковий стан сховища,
  • об’єкт, що встановлює відповідність між типами дій та редьюсерами, кожен із яких обробляє якийсь певний тип.

Скориставшись методом createReducer, отримаємо наступний код:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 loadingState: 'none',
 error: null,
};

const counterReducer = createReducer(initialState, {
 [productReleasesFetching]: (state, action) => {
   state.fetchingState = 'requesting'
 },
 [productReleasesFetched.type]: (state, action) => {
   state.productReleases = action.payload.productReleases;
   state.fetchingState = 'success';
 },
 [productReleasesFetchingError]: (state, action) => {
   state.fetchingState = 'failed';
   state.error = action.payload.error;
 },

…

 [productReleaseUpdating]: (state) => {
   state.updatingState = 'requesting'
 },
 [productReleaseUpdated]: (state, action) => {
   state.updatingState = 'success';
   state.productReleases = state.productReleases.map(productRelease => {
     if (productRelease.id === action.payload.productRelease.id)
       return action.payload.productRelease;
     return productRelease;
   });
 },
 [productReleaseUpdatingError]: (state, action) => {
   state.updating = 'failed';
   state.error = action.payload.error;
 },
});

Як бачимо, використання функцій createAction і createReducer істотно вирішує проблему написання зайвого коду, але проблема попереднього створення констант все одно залишається. Тому розглянемо більш потужний варіант, що поєднує в собі генерацію і творців дій та редьюсера – функція createSlice.

Функція createSlice

Як вхідні параметри функція createSlice приймає об’єкт із наступними полями:

  • name – простір імен створюваних дій ( ${name}/${action.type});
  • initialState – початковий стан ред’юсера;
  • reducers – об’єкт з обробниками. Кожен обробник приймає функцію з аргументами state і action, action містить у собі дані у властивості payload та ім’я події у властивості name. Крім того, є можливість попередньої зміни даних, отриманих з події, перед їх потраплянням до редьюсера (наприклад, додати id до елементів колекції). Для цього замість функції необхідно передати об’єкт з полями reducer та prepare, де reducer – це функція-обробник дії, а prepare – функція-обробник корисного навантаження, що повертає оновлений payload;
  • extraReducers – об’єкт, що містить редьюсери іншого зрізу. Цей параметр може знадобитися в разі потреби оновлення об’єкта, що стосується іншого зрізу. Докладніше про цю можливість можна дізнатися з відповідного розділу офіційної документації.

Результатом роботи функції є об’єкт, званий “зріз (slice)”, з наступними полями:

  • name – ім’я зрізу,
  • reducer – ред’юсер,
  • actions – набір дій.

Використовуючи цю функцію для вирішення нашого завдання, отримаємо наступний вихідний код:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 loadingState: 'none',
 error: null,
};

const productReleases = createSlice({
 name: 'productReleases',
 initialState,
 reducers: {
   productReleasesFetching: (state) => {
     state.fetchingState = 'requesting';
   },
   productReleasesFetched: (state, action) => {
     state.productReleases = action.payload.productReleases;
     state.fetchingState = 'success';
   },
   productReleasesFetchingError: (state, action) => {
     state.fetchingState = 'failed';
     state.error = action.payload.error;
   },

…

   productReleaseUpdating: (state) => {
     state.updatingState = 'requesting'
   },
   productReleaseUpdated: (state, action) => {
     state.updatingState = 'success';
     state.productReleases = state.productReleases.map(productRelease => {
       if (productRelease.id === action.payload.productRelease.id)
         return action.payload.productRelease;
       return productRelease;
     });
   },
   productReleaseUpdatingError: (state, action) => {
     state.updating = 'failed';
     state.error = action.payload.error;
   },
 },
});

Тепер витягнемо із створеного зрізу творці дій та ред’юсер.

const { actions, reducer } = productReleases;

export const {
  productReleasesFetched, productReleasesFetching,
  productReleasesFetchingError,
…
  productReleaseUpdated,
  productReleaseUpdating, productReleaseUpdatingError
} = actions;

export default reducer;

Вихідний код творців дій, що містять виклики API, не змінився, за винятком способу передачі параметрів під час надсилання дій:

export const fetchProductReleases = () => (dispatch) => {
 dispatch(productReleasesFetching());
 return productReleasesService
   .getProductReleases()
   .then((productReleases) => dispatch(productReleasesFetched({ productReleases })))
   .catch((error) => {
     error.clientMessage = "Can't get product releases";
     dispatch(productReleasesFetchingError({ error }));
   });
};

…

export const updateProductRelease = (id, productName, productVersion, releaseDate) => (dispatch) => {
 dispatch(productReleaseUpdating());
 return productReleasesService
   .updateProductRelease(id, productName, productVersion, releaseDate)
   .then((productRelease) => dispatch(productReleaseUpdated({ productRelease })))
   .catch((error) => {
     error.clientMessage = "Can't update product releases";
     dispatch(productReleaseUpdatingError({ error }));
   });

Наведений вище код показує, що функція createSlice дозволяє позбутися значної частини бойлерплейта при роботі з Redux, що дозволяє не тільки зробити код компактнішим, лаконічнішим і зрозумілішим, але й витрачати менше часу на його написання.

Підсумок

На завершення цієї статті, хотілося б сказати, що незважаючи на те, що бібліотека Redux Toolkit не вносить нічого нового в управління сховищем, вона надає ряд набагато зручніших засобів для написання коду, ніж були до цього. Дані засоби дозволяють не тільки зробити процес розробки більш зручним, зрозумілим і швидким, але і більш ефективним, за рахунок наявності в бібліотеці ряду інструментів, що добре зарекомендували себе раніше.

Переклад статті “Redux Toolkit как средство эффективной Redux-разработки