Redux и Thunk вместе с React. Руководство для чайников.

Redux и Thunk вместе с React. Руководство для чайников.
mangohost

Если вы также как и я читали документацию к Redux, смотрели видео от Dan'а, прошли курс от Wes'а и до сих пор не совсем понимаете как использовать Redux, то я надеюсь что эта статья поможет вам.

После нескольких попыток использования Redux я начал что-то понимать и подумал, что лучше записать процесс преобразования уже существующего приложения в приложение использующее Redux и Redux Thunk. Если вы не знаете что такое Thunk, не беспокойтесь, мы будем использовать его для отправки асинхронных запросов с Redux.

Это руководство предполагает, что у вас есть базовые знания React и ES6/2015.

Способ без Redux

Давайте начнём с создания React компонента components/ItemList.js , который будет отвечать за получение и отображение некоторого списка элементов.

Создание основы

Для начала мы создадим статический компонент со свойством state, которое содержит некоторые элементы для вывода (items) и два логических свойства чтобы отображать что-то другое при загрузке или ошибке (hasErrored и isLoading соответственно).

import React, { Component } from 'react';

class ItemList extends Component {
    constructor() {
        super();

        this.state = {
            items: [
                {
                    id: 1,
                    label: 'List item 1'
                },
                {
                    id: 2,
                    label: 'List item 2'
                },
                {
                    id: 3,
                    label: 'List item 3'
                },
                {
                    id: 4,
                    label: 'List item 4'
                }
            ],
            hasErrored: false,
            isLoading: false
        };
    }

    render() {
        if (this.state.hasErrored) {
            return <p>Sorry! There was an error loading the items</p>;
        }

        if (this.state.isLoading) {
            return <p>Loading…</p>;
        }

        return (
            <ul>
                {this.state.items.map((item) => (
                    <li key={item.id}>
                        {item.label}
                    </li>
                ))}
            </ul>
        );
    }
}

export default ItemList;

Не так уж и много, но это хорошее начало.

При визуализации компонент должен отобразить 4 элемента из списка, но только если isLoading или hasErrored не установлены в значение true, иначе отобразиться соответствующий текст.

Делаем его динамичным

Жёстко закодированные элементы не сделают ничего хорошего для нашего компонента, поэтому давайте получим наш массив items через JSON API, который также позволит нам устанавливать isLoading и hasErrored в зависимости от обстоятельств.

В нашем случае ответ API будет идентичен тому что мы жёстко закодировали в предыдущем примере, но в реальном приложении вы можете получать например список книг, новые записи в блоге, короче то что нужно вашему приложению.

Чтобы получить элементы мы воспользуемся такой штукой, которая называется Fetch API. Fetch позволяет выполнять запросы намного проще чем классический XMLHttpRequest и возвращает промис (который важен для Thunk). Fetch доступен не для всех браузеров, поэтому нам нужно добавить его в зависимости нашего проекта:

npm install whatwg-fetch --save

Преобразования на самом деле очень простые.

  1. Во-первых мы установим изначальное состояние items в виде пустого массива [];
  2. Далее мы добавим метод получения данных, в котором также будем устанавливать состояние загрузки и ошибок:
    fetchData(url) {
        this.setState({ isLoading: true });
    
        fetch(url)
            .then((response) => {
                if (!response.ok) {
                    throw Error(response.statusText);
                }
    
                this.setState({ isLoading: false });
    
                return response;
            })
            .then((response) => response.json())
            .then((items) => this.setState({ items })) // ES6 property value shorthand for { items: items }
            .catch(() => this.setState({ hasErrored: true }));
    }
  3. А вызывать мы его будем, когда компонент будет смонтирован:
    componentDidMount() {
        this.fetchData('http://5826ed963900d612000138bd.mockapi.io/items');
    }

Вот что у нас получится в итоге (не изменённые строки опущены):

class ItemList extends Component {
    constructor() {
        this.state = {
            items: [],
        };
    }

    fetchData(url) {
        this.setState({ isLoading: true });

        fetch(url)
            .then((response) => {
                if (!response.ok) {
                    throw Error(response.statusText);
                }

                this.setState({ isLoading: false });

                return response;
            })
            .then((response) => response.json())
            .then((items) => this.setState({ items }))
            .catch(() => this.setState({ hasErrored: true }));
    }

    componentDidMount() {
        this.fetchData('http://5826ed963900d612000138bd.mockapi.io/items');
    }

    render() {
    }
}

Вот и всё. Теперь компонент получает данные из REST API! Вы с надеждой смотрите на надпись "Loading…" перед тем как появятся 4 элемента из списка. Если вы передадите не рабочий URL в fetchData, то появится сообщение об ошибке.

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

Преобразование в Redux

Есть несколько основных принципов в Redux, которые нужно понимать:

  1. Есть 1 глобальный объект состояния, который управляет состоянием всего приложения. В этом примере он будет идентичен начальному состоянию компонента. Это единственный источник истины.
  2. Единственный способ изменить состояние - это отправка действий. Действия представляют собой объекты описывающие то, что должно измениться. Создатели действий (Action Creators) - это функции, которые могут быть отправлены (dispatched). Всё что они делают это возвращают действие.
  3. Когда действие отправлено, редьюсер (функция) либо изменяет состояние в соответствии с отправленным действием, либо возвращает текущее состояние если действие не применимо к редьюсеру.
  4. Редюсеры - это "чистые функции". Они не должны изменять состояние, вместо этого они должны возвращать модифицированную копию.
  5. Индивидуальные редьюсеры объединены в один корневой редьюсер (rootReducer) для создания отдельных свойств состояния.
  6. Хранилище (store) - это такая штука, которая всё это объединяет: оно представляет состояние используя корневой редьюсер, какие-то промежуточные слои (middleware, в нашем случае Thunk), и позволяет фактически отправлять действия.
  7. Для того чтобы использовать Redux вместе с React существует компонент <Provider />, который оборачивает всё наше приложение и передаёт хранилище store всем дочерним элементам.

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

Проектирование нашего состояния

Из той работы которую мы уже проделали, мы знаем что у нашего состояния должно быть 3 свойства: items, hasErrored и isLoading для того чтобы наше приложение работало должным образом при любых обстоятельствах. Соответственно нам нужны 3 уникальных действия.

Теперь я расскажу почему создатели действий (action creators) отличаются от действий (actions) и почему необязательно иметь связь 1 к 1. Нам нужен четвёртый создатель действия, который будет вызвать 3 других наших действия в зависимости от статуса получения данных. Этот четвёртый создатель действий будет идентичен нашему оригинальному методу fetchData, но вместо установки состояния с помощью this.setState({ isLoading: true }) он будет отправлять действие dispatch(isLoading(true)).

Создание наших действий

Давайте создадим файл actions/items.js, который будет содержать наши создатели действий. Начнём с трёх простых действий.

export function itemsHasErrored(bool) {
    return {
        type: 'ITEMS_HAS_ERRORED',
        hasErrored: bool
    };
}

export function itemsIsLoading(bool) {
    return {
        type: 'ITEMS_IS_LOADING',
        isLoading: bool
    };
}

export function itemsFetchDataSuccess(items) {
    return {
        type: 'ITEMS_FETCH_DATA_SUCCESS',
        items
    };
}

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

Два первых создателя действий принимают логическое значение bool (true/false) в качестве аргумента и возвращают объект содержащий type и bool в соответствующих свойствах.

Третье действие itemsFetchSuccess() будет вызвано когда данные будут успешно получены, они будут переданы ему в качестве параметра items.

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

Теперь когда у нас есть 3 действия, которые будут представлять наше состояние, мы преобразуем оригинальный метод fetchData() нашего компонента в метод itemsFetchData() нашего создателя действия.

По умолчанию создатели действий в Redux не поддерживают асинхронные действия, такие как получение данных, поэтому мы будем использовать Redux Thunk. Thunk позволяет нам писать создатели действий, которые возвращают функцию вместо самого действия. Эта внутренняя функция может в качестве параметров принимать методы хранилища (store) такие как dispatch и getState , но мы будем использовать только dispatch.

По настоящему простым примером может быть отправка действия itemsHasErrored() через пять секунд.

export function errorAfterFiveSeconds() {
    // We return a function instead of an action object
    return (dispatch) => {
        setTimeout(() => {
            // This function is able to dispatch other action creators
            dispatch(itemsHasErrored(true));
        }, 5000);
    };
}

Теперь мы знаем что такое Thunk, поэтому мы можем написать itemsFetchData().

export function itemsFetchData(url) {
    return (dispatch) => {
        dispatch(itemsIsLoading(true));

        fetch(url)
            .then((response) => {
                if (!response.ok) {
                    throw Error(response.statusText);
                }

                dispatch(itemsIsLoading(false));

                return response;
            })
            .then((response) => response.json())
            .then((items) => dispatch(itemsFetchDataSuccess(items)))
            .catch(() => dispatch(itemsHasErrored(true)));
    };
}

Создание редьюсеров

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

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

Каждый редьюсер принимает два параметра: предыдущее состояние (state) и объект действия (action).

Внутри каждого редьюсера, мы используем конструкцию switch чтобы определить какое действие было передано в action.type. Хоть это может показаться излишним в наших простых редьюсерах, но редьюсеры теоретически могут быть большими, поэтому использование конструкций if/else if/else может сильно ухудшить читаемость вашего кода.

Если action.type  совпадает с текущим действием, то мы возвращаем подходящее свойство этого действия. Как уже говорилось ранее, action.type и action[propertyName] это то, что мы определяли в создателях действий.

Хорошо, теперь давайте создадим редьюсеры наших элементов в reducers/items.js.

export function itemsHasErrored(state = false, action) {
    switch (action.type) {
        case 'ITEMS_HAS_ERRORED':
            return action.hasErrored;

        default:
            return state;
    }
}

export function itemsIsLoading(state = false, action) {
    switch (action.type) {
        case 'ITEMS_IS_LOADING':
            return action.isLoading;

        default:
            return state;
    }
}

export function items(state = [], action) {
    switch (action.type) {
        case 'ITEMS_FETCH_DATA_SUCCESS':
            return action.items;

        default:
            return state;
    }
}

Важно что каждый редьюсер назван в соответствии со свойствами состояния, с action.type соответствовать необязательно. Первые 2 редьюсера я думаю не нуждаются в пояснении, но вот последний, items(), немного отличается от других.

Отличается он потому что у него может быть несколько условий, например он может возвращать массив со всеми элементами, а может возвращать какое-то подмножество после того как было отправлено действие "удаления", а может вообще возвращать пустой массив, если были удалены все элементы.

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

Все индивидуальные редьюсеры нужно объединить в один корневой редьюсер rootReducer, чтобы создать единый объект.

Создайте новый файл reducers/index.js.

import { combineReducers } from 'redux';
import { items, itemsHasErrored, itemsIsLoading } from './items';

export default combineReducers({
    items,
    itemsHasErrored,
    itemsIsLoading
});

Мы импортировали каждый редьюсер из items.js и экспортировали их с помощью Redux функции combineReducers(). Так как наши имена идентичны тем, которые мы хотим использовать в качестве имён свойств нашего состояния, мы можем использовать сокращённую возможность ES6.

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

Например:

import { combineReducers } from 'redux';
import { items, itemsHasErrored, itemsIsLoading } from './items';
import { posts, postsHasErrored, postsIsLoading } from './posts';

export default combineReducers({
    items,
    itemsHasErrored,
    itemsIsLoading,
    posts,
    postsHasErrored,
    postsIsLoading
});

Настройка хранилища и предоставление его приложению

Это довольно просто. Давайте создадим файл store/configureStore.js  со следующим содержимым:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../reducers';

export default function configureStore(initialState) {
    return createStore(
        rootReducer,
        initialState,
        applyMiddleware(thunk)
    );
}

Теперь изменим файл index.js  нашего приложения и добавим в него <Provider /> , configureStore , настроим наше хранилище и обвернём приложение (<ItemsList />) чтобы передать хранилище (store) в качестве свойств (props).

import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from './store/configureStore';

import ItemList from './components/ItemList';

const store = configureStore(); // You can also pass in an initialState here

render(
    <Provider store={store}>
        <ItemList />
    </Provider>,
    document.getElementById('app')
);

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

Преобразование компонента для использования Redux хранилища и методов

Давайте вернёмся назад к components/ItemList.js.

В верхней части файла нужно изменить секцию import:

import { connect } from 'react-redux';
import { itemsFetchData } from '../actions/items';

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

После определения класса нашего компонента нам нужно сопоставить состояние Redux и отправку нашего создателя действия свойствам компонента.

Мы создаём функцию, которая принимает состояние (state) и возвращает объект свойств. В простом компоненте как этот я удалил префиксы для свойств has/is так как очевидно, что они относятся к items.

const mapStateToProps = (state) => {
    return {
        items: state.items,
        hasErrored: state.itemsHasErrored,
        isLoading: state.itemsIsLoading
    };
};

И нам нужна ещё одна функция, чтобы иметь возможность отправлять наш создатель действий itemsFetchData() со свойством.

const mapDispatchToProps = (dispatch) => {
    return {
        fetchData: (url) => dispatch(itemsFetchData(url))
    };
};

Опять же я удалил префикс items из свойства возвращаемого объекта. fetchData - это функция, которая принимает url в качестве параметра и возвращает отправленный itemsFetchData(url).

Пока что эти 2 функции ничего не делают, так как нам нужно изменить завершающую строку экспорта (export).

export default connect(mapStateToProps, mapDispatchToProps)(ItemList);

Эта строка соединяет наш ItemList с Redux, чтобы во время отображение мы могли использовать свойства.

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

  • Удалите методы constructor() {}  и fetchData() {}, так как они больше не нужны;
  • Измените this.fetchData() в componentDidMount() на this.props.fetchData();
  • Измените this.state.X на this.props.X для .hasErrored, .isLoading и .items.

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

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { itemsFetchData } from '../actions/items';

class ItemList extends Component {
    componentDidMount() {
        this.props.fetchData('http://5826ed963900d612000138bd.mockapi.io/items');
    }

    render() {
        if (this.props.hasErrored) {
            return <p>Sorry! There was an error loading the items</p>;
        }

        if (this.props.isLoading) {
            return <p>Loading…</p>;
        }

        return (
            <ul>
                {this.props.items.map((item) => (
                    <li key={item.id}>
                        {item.label}
                    </li>
                ))}
            </ul>
        );
    }
}

const mapStateToProps = (state) => {
    return {
        items: state.items,
        hasErrored: state.itemsHasErrored,
        isLoading: state.itemsIsLoading
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        fetchData: (url) => dispatch(itemsFetchData(url))
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(ItemList);

Вот и всё. Теперь приложение использует Redux и Redux Thunk для получения и отображения данных.

Это было не так сложно, не так ли?

Теперь вы Redux мастер :D

Что дальше?

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

Комментарии

23 ноября 2016 01:24
Sergey Bulavyk

Отличная статья! Спасибо за перевод! Правда непонятен момент, почему автор в пункте о создании редьюсеров разделил три простых кейса на три отдельных редьюсера...Мне кажется, удобнее было бы оформить их в один редьюсер.

27 ноября 2016 16:05
Администратор

В один редьюсер они объединяются с помощью специальной функции combineReducers(). А разделяют их специально, чтобы разделить логику.

28 ноября 2016 11:57
Sergey Bulavyk

Да, само собой, думаю это было действительно сделано для демонстративного примера разделения логики. Спасибо!

25 февраля 2018 15:46
Администратор

Это было сделано не для демонстративного примера. Это общепринятая практика.

27 февраля 2017 00:08
Дмитрий

Спасибо за статью! Не совсем понятен этот момент: fetchData: (url) =&gt; dispatch(itemsFetchData(url)) В dispatch должен передаваться объект-action, а itemsFetchData(url) ничего не возвращает, а только выполняет dispatch'и внутри себя. Соответственно Thunk разруливает как-то эту ситуацию. Я правильно понимаю?

27 июня 2019 21:09
dmitriy

Спасибо за статью! Очень кстати рассказано про получение данных с сервера!

mangohost