Redux - Predictable state container for JavaScript apps

Redux — маленькая и простая библиотека. Первые несколько уроков мы набьём руку по работе с ним, а уже после начнём интегрироваться с React.

Redux — это такая база данных в памяти. Она хранит внутри себя состояние приложения, аналогично тому, как React хранит состояние внутри себя. Ключевых отличия два:

  1. Redux, с точки зрения кода, это объект, внутри которого находится состояние приложения. Он, как правило, один на всё приложение, независимо от используемого фреймворка для UI.
  2. Обновление состояния внутри Redux выполняется не прямым изменением данных (как в React: this.setState({ key: 'value' })), а через указание "действий" (actions). Сам же способ обновления данных описывается внутри объекта Redux.

Пример использования Redux:

import { createStore } from 'redux';

const reducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};

const store = createStore(reducer);

По шагам:

  1. Импортируется функция createStore, которая создаёт контейнер. Контейнер это и есть наша база данных.
  2. Далее определяется reducer — функция, которая принимает на вход state и action. На выходе из функции возвращается новый state. Именно из-за сходства работы этой функции с тем как работает reduce, она имеет название reducer.
  3. Редьюсер передаётся в функцию createStore и на выходе мы имеем готовый к использованию контейнер.

Во время вызова createStore(reducer), происходит вызов самого редьюсера. Вызов выглядит так:

// благодаря тому, что первым параметром передаётся undefined, внутри редьюсера значение state
// становится равно своему значению по умолчанию, то есть 0
// затем, внутри switch, отрабатывает default-ветка, которая возвращает этот state наружу
reducer(undefined, { type: '@@INIT' }); // 0

Результат этого вызова запоминается внутри store и считается начальным состоянием. Все дальнейшие изменения идут относительно него.

Теперь использование:

// Функция `subscribe` является частью реализации паттерна Observer.
// Каждый её вызов добавляет в список наблюдателей новую функцию.
// Затем, как только меняются данные в хранилище, вызываются по очереди все наблюдатели.
store.subscribe(() =>
  console.log(store.getState());
);

// Для избежания дублирования и повышения уровня абстракции, вынесем действия в функции
const increment = () => ({ type: 'INCREMENT' });
const decrement = () => ({ type: 'DECREMENT' });

store.dispatch(increment()); // => 1
store.dispatch(increment()); // => 2
store.dispatch(decrement()); // => 1

Единственный способ произвести изменения состояния в хранилище — это передать/отправить действие (action) в функцию dispatch. Действие — обычный JS-объект, в котором присутствует минимум одно свойство — type. Никаких ограничений на содержимое этого свойства не накладывается, главное, чтобы внутри контейнера был подходящий ему обработчик.

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

Отправка действия
(Redux)

Посмотрим ещё один пример с использованием массива и передачей данных через действие:

// payload - свойство внутри которого хранятся данные
const addUser = (user) => ({ type: 'USER_ADD', payload: { user } });

const reducer = (state = [], action) => { // инициализация состояния
  switch (action.type) {
    case 'USER_ADD': {
      const user = action.payload.user; // данные
      return [...state, user]; // Immutability
    }
    case 'USER_REMOVE': {
      const id = action.payload.id; // данные
      return state.filter(u => u.id !== id); // Immutability
    }
    default:
      return state;
  }
};

const user = /* ... */;
store.dispatch(addUser(user));

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

Устройство Redux

Для написания самой простой версии Redux, нужно всего 7 строчек. Вот они:

const createStore = (reducer, initialState) => {
  let state = initialState;
  return {
    dispatch: action => { state = reducer(state, action) },
    getState: () => state,
  }
}

Три принципа

Подведём итог. Что главное в Redux:

  • Single source of truth — используя Redux, мы работаем только с одним контейнером на приложение. Это одно из ключевых отличий от Flux-архитектуры. Всё состояние в одном месте.
  • State is read-only — данные меняются только косвенно, используя функциональный стиль.
  • Changes are made with pure functions — внутри хранилища можно использовать только чистые функции. Тут правила даже строже чем во Flux, так как не позволяется использовать даже Date.now() и ему подобные функции, которые хотя и не обладают побочными эффектами, но все же являются недетерминированными. Все подобные вызовы должны делаться до вызова dispatch (подробнее об этом далее).

Начальное состояние

Я говорил про то, что начальное состояние задаётся в определении редьюсера:

const reducer = (state = 0, action) { /* ... */ }

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

const store = createStore(reducer, initState);
// @@redux/INIT

Redux посылает специальное действие, которое нельзя перехватывать. Если редьюсер реализован правильно и содержит секцию default в switch, то контейнер заполнится данными из initState. Пример:

// const reducer = (state = 0, action) => {
//   switch (action.type) {
//     case 'INCREMENT':
//       return state + 1;
//     case 'DECREMENT':
//       return state - 1;
//     default:
//       return state;
//   }
// };
const store = createStore(reducer, 100);

В коде выше, функция createStore вызовет редьюсер так: reducer(100, '@@redux/INIT'). Затем выполнится ветка default и состоянием контейнера станет число 100.

Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Node, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →