Что такое Redux?

Redux — это инструмент для управления состоянием приложения. Построен на принципах технологии Flux и функционального программирования. Создан компанией FaceBook, но вопреки распространенному мнению может использоваться не только в связке с React, но также и с другими фреймворками/библиотеками.

Flux — это тип архитектуры и набор паттернов проектирования веб-приложений. Подробнее на Wiki

Redux использует методологию flux. Она состоит из 4 понятий:

  • Пользовательский интерфейс (View) — в React это компоненты
  • Хранилище (Store)
  • Диспатчер
  • Action

В React по умолчанию нет какого-то глобального state (состояния), которое было бы доступно во всем приложении. Вы можете только сохранять данные в рамках одного компонента. К примеру, у вас есть интернет магазин и в нем есть корзина с товарами. Если работать только со стейтом компонента Корзина, то вам эти данные будут недоступны в других компонентах. Также например, у вас есть иконка корзины в углу экрана, которая должна показывать количество товара, которые пользователь добавил туда. Так вот средствами чисто React, это будет сложно реализовать.

Вот именно поэтому есть такие библиотеки как Redux, для хранения всех данных приложения в одном месте и удобного их обновления.

Основные понятия Redux

Как я уже писал выше, основные понятия редакса — actions, dispatcher, store.

Store — это состояние веб-компонента, которое хранит в себе всю информацию (или ту которую вы решили сохранить в него). В дальнейшем стор будет доступен из любого компонента вашего приложения.

Action — действие, описывает что нужно сделать. Согласно принципам функционального программирования, мы не можем изменять объект напрямую, поэтому нам нужны экшены, чтобы передать их в диспатчер и «сказать», что нужно сделать.

Dispatcher — сообщает хранилищу о каком-то действии (action) и передает ему обновленную информацию.

Теперь когда мы разобрали основные понятия, давайте посмотрим как работает Redux:

Схема работы Redux

Компонент генерирует действие (action), диспатчер сообщает об этом хранилищу (store), хранилище изменяет состояние и данные передаются в компонент (View).

Есть еще одно понятие в Redux это reducer (редюсер). Редюсер — это чистая функция, которая принимает как аргумент хранилище и экшен. Основные правила редюсеров:

  • В этих функциях не должно быть «side effects» (побочных эффектов). Например, нельзя делать API запрос для получения каких-либо данных
  • Они не должны мутировать (изменять) принятые аргументы или состояние.
  • Нельзя вызывать нечистые функции внутри редюсеров (например, Date.now() или Math.random())

Более подробно про чистые функции можно прочитать тут.

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

Теперь на простом практическом примере разберем как работать с Redux.

Мы сделаем простое приложение ToDo, которое даст возможность создавать свои таски с сохранением их в store. Это будет простое приложение для примера, основной упор сделан на работу с Redux.

Итак, есть 2 варианта, вы можете скачать стартовый проект и просто запустить установку, или пошагово пройти и создать проект со старта.

Установка и настройка проекта

Чтобы создать приложение заново, открываем командную строку или Git Bash

npx create-react-app redux-first-app

Далее заходим в папку проекта и устанавливаем Redux и пакет для Реакта — react-redux

cd redux-first-app/

// Затем
npm i redux react-redux

Если вы скачали архив с уже готовым приложением, тогда его нужно распаковать, войти в папку с приложением и в командной строке/терминале запустить команду:

npm i 

Теперь, чтобы запустить наш проект нужно воспользоваться следующей командой:

npm run start

Проект будет собран и запущен, автоматически откроется вкладка в браузере

Результат выполнения команды npm run start

Для того, чтобы не верстать всё заново, мы используем Bootsrap. Давайте его установим.

npm install react-bootstrap bootstrap

Также подключим стили в файле src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import 'bootstrap/dist/css/bootstrap.min.css';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

reportWebVitals();

Создание базовой структуры для хранилища

Теперь давайте сделаем базовую структуру для Redux. Создадим папку src/store, а в ней 4 файла

  • actions.js
  • actionTypes.js
  • reducer.js
  • store.js

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

В нашем приложении, например, нам нужен будет экшен в 2 файлах: actions и reducer. Создадим файл actionTypes.js и в нем определим наши типы:

export const TASK_ADD = 'TASK_ADD';
export const TASK_TOGGLE = 'TASK_TOGGLE';
export const TASK_REMOVE = 'TASK_REMOVE';

В файле store/actions.js мы опишем все экшены, которые нам потребуются для приложения:

import * as actions from './actionTypes';

export const addTask = task => ({
  type: actions.TASK_ADD,
  payload: task
});

export const toggleTask = id => ({
  type: actions.TASK_TOGGLE,
  payload: { id }
});

export const removeTask = id => ({
  type: actions.TASK_REMOVE,
  payload: { id }
})

Выше вы видите типичную структуру экшена: это функция, которая возвращает объект с двумя свойствами:

  • type — тип экшена (мы его определяли в actionTypes)
  • payload — данные, которые нам нужно передать в редюсер

Теперь давайте рассмотрим функцию редюсер (store/reducer.js):

import * as actions from './actionTypes';

let lastId = 0;

export default function reducer(state = [], action) {
  switch (action.type) {
    case actions.TASK_ADD:
      return [...state, {
        id: ++lastId,
        title: action.payload.title,
        completed: false,
      }];

    case actions.TASK_TOGGLE:
      return state.map(task => {
        if (task.id === action.payload.id)
          return { ...task, completed: !task.completed }
        return task;
      });

    case actions.TASK_REMOVE:
      return state.filter(task => action.payload.id !== task.id);

    default:
      return state;
  }
}

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

Сам reducer принимает в качестве аргументов state (или равняется пустому массиву) и экшен. Далее мы проверяем тип екшена и в зависимости от этого производим определенные манипуляции со стейтом.

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

[...state, {
  id: ++lastId, // увеличиваем id на единицу
  title: action.payload.title, // в качестве title задаем значение переданное пользователем
  completed: false, // у нового таска статус будет не выполнен
}];

Далее на основании этого редюсера нам нужно создать store с помощью функции createStore. Создадим файл store.js с таким содержимым:

import { createStore } from 'redux';
import reducer from "./reducer";

const store = createStore(
  reducer,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);

export default store;


* У меня также вторым параметром добавлена след. строка

window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()

Это для работы плагина для Chrome — Redux DevTools. Удобный плагин для дебагинга.

Теперь после создания store, мы можем использовать его в любом модуле нашего приложения.

Основные методы для работы со store

  • store.dispatch() — диспатч какого-либо экшена
  • store.getState() — получение данных, которые хранятся в store
  • store.subscribe() — подписка на изменения store.

Redux в функциональных компонентах (хуки)

Наше приложение будет построено при помощи функциональных компонентов и хуков, поэтому мы немного рассмотрим какие хуки предоставляет нам Redux для работы в таких компонентах.

  • useDispatch() — замена для mapDispatchToProps(). Этот хук возвращает dispatch метод. С его помощью мы потом можем диспатчить нужные экшены.
  • useSelector() — аналог mapStateToProps() — Этот хук принимает колбэк, который в качестве аргумента принимает текущий state. Вы можете вернуть весь state или какие-то определенные данные из него.
  • useStore() — этот хук возвращает ссылку на тот же state, который был передан в <Provider>. В документации редакса говорится о том, что лучше этот хук не использовать часто, а лучше пользоваться useSelector()

Теперь давайте вернемся к нашему приложению. В index.js нам нужно обернуть наше приложение в компонент Provider и передать ему store через пропсы. Store мы создали в файле store/store.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import 'bootstrap/dist/css/bootstrap.min.css';
import store from './store/store';

ReactDOM.render(
  <Provider store={ store }>
    <React.StrictMode>
      <App />
    </React.StrictMode>
  </Provider>,
  document.getElementById('root')
);

reportWebVitals();

Следующим шагом давайте создадим папку, в которой будем хранить наши компоненты и назовем ее componetns. В ней создадим 3 файла:

  • Task.js — этот компонент будет отвечать за вывод отдельного таска
  • TaskList.js — это будет список тасков
  • AddNewTask.js — а этот компонент будет отвечать за создание нового таска

src/componetns/Task.js

import React from "react";
import { useDispatch } from "react-redux";
import { Form, ListGroup } from "react-bootstrap";
import { toggleTask, removeTask }  from "../store/actions";

const Task = ({ task }) => {
  const { id, title, completed } = task;
  const dispatch = useDispatch(); // Получаем диспатч из хука

  return (
    <ListGroup.Item className={completed && 'task-completed'}>
      <Form.Check
        id={id}
        type="checkbox"
        label={title}
        checked={completed}
        onChange={ () => dispatch(toggleTask(id)) } // диспатчим нужный экшен по время клика по таску
      />
      <div className="list-group-item-actions">
        <span onClick={() => dispatch(removeTask(id))}>Удалить</span> // диспатчим экшен removeTask для удаления таска из стора
      </div>
    </ListGroup.Item>
  )
}

export default Task;

Для начала импортируем хук useDispatch(), т.к. в этом компоненте мы будем диспатчить 2 экшена: выполнение таска (toggleTask) и удаление (removeTask). В компоненте <Form.Check> у нас есть событие onChange — при клике на этот компонент мы будем диспатчить экшен для переключения состояния таска, ну а при клике кнопки удалить — диспатчим экшен для удаления таска из нашего стора.

Далее файл src/components/TaskList.js

import React from 'react';
import { ListGroup } from "react-bootstrap";
import Task from './Task'; // импортируем компонент таска, созданный ранее

const TaskList = ({ tasks }) => {

  if (tasks.length)
    return (
      <ListGroup>
        {
          // пробегаемся методом map по нашему массиву с тасками и выводим для каждый таск
          tasks.map(task =>
            <Task key={task.id} task={task} /> 
          )
        }
      </ListGroup>
    )
  else return null;
}

export default TaskList;

Теперь последний компонент AddNewTask.js

import React, { useState } from 'react';
import { Button, FormControl, InputGroup } from "react-bootstrap";
import * as actions from "../store/actions";
import { useDispatch } from "react-redux";

const AddNewTask = () => {
  const [taskTitle, setTaskTitle] = useState('');
  const dispatch = useDispatch();

  const handleTaskTitleChange = (e) => {
    setTaskTitle(e.target.value);
  }

  const handleTaskSubmit = () => {
    dispatch(actions.addTask({
      title: taskTitle
    }));
    setTaskTitle('');
  }

  return (
    <InputGroup className="mb-3">
      <FormControl placeholder="Добавить новый таск" value={taskTitle} onChange={e => handleTaskTitleChange(e)} />
      <InputGroup.Append>
        <Button variant="outline-secondary" onClick={handleTaskSubmit}>Сохранить</Button>
      </InputGroup.Append>
    </InputGroup>
  )
}

export default AddNewTask;

Тут у нас будет 2 обработчика handleTaskTitleChange() и handleTaskSubmit(). Последний будет диспатчить экшен для создания нового таска. В идеале, тут бы еще добавить проверку на пустую строку и обрезать лишние пробелы, но у нас не об этом сейчас 🙂

Теперь остался заключительный шаг, изменить файл scr/App.js и добавить немного стилей

import React from 'react';
import { Container, Col, Row } from "react-bootstrap";
import TaskList from "./components/TaskList";
import AddNewTask from "./components/AddNewTask";
import { useSelector } from "react-redux";
import './App.css';

function App() {
  // Получаем наш state
  const tasks = useSelector(state => state);

  return (
    <Container className="main-app-container">
      <Row className="mb-4">
        <Col className="mt-4">
          <AddNewTask />
        </Col>
      </Row>
      <Row>
        <Col md={12}>
          <h4>Список задач</h4>
        </Col>
        <Col>
          <TaskList tasks={ tasks } />
        </Col>
      </Row>
    </Container>
  );
}

export default App;

В файле App.css

.main-app-container .list-group-item {
  display: flex;
}

.list-group-item .form-check {
  width: 90%;
}

.list-group-item-actions {
  width: 10%;
  text-align: right;
}

.list-group-item-actions span {D
  display: none;
  color: #c00;
}

.main-app-container .list-group-item:hover .list-group-item-actions span {
  display: inline;
  cursor: pointer;
}

.list-group-item-actions span:hover {
  text-decoration: underline;
}

.list-group-item.task-completed {
  background-color: #f2f2f2;
}

.list-group-item.task-completed .form-check-label {
  text-decoration: line-through;
}

В итоге у нас получилась вот такое приложение

Итоговое приложение

Ваши вопросы и комментарии: