Асинхронные actions

ОБНОВЛЕНИЕ 2018: Вышло второе издание (современный код и версии пакетов, данное издание УСТАРЕЛО)

На канале так же проводятся бесплатные вебинары, публикуются переводы и авторские материалы, присоединяйтесь!

Асинхронные actions

Давайте представим синхронное действие: 1. Пользователь кликнул на кнопку 2. dispatch action {type: ТИП_ДЕЙСТВИЯ, payload: доп.данные} 3. интерфейс обновился

Давайте представим асинхронное действие: 1. Пользователь кликнул на кнопку 2. dispatch action {type: ТИП_ДЕЙСТВИЯ_ЗАПРОС} 3. запрос выполнился успешно 4. dispatch action {type: ТИП_ДЕЙСТВИЯ_УСПЕШНО, payload: доп.данные} 4. запрос выполнился неудачно 5. dispatch action {type: ТИП_ДЕЙСТВИЯ_НЕУДАЧНО, error: true, payload: доп.данные ошибки}

Благодаря такой схеме, в reducer'e мы сможем реализовать подобное:

switch(тип_действия)
    case ТИП_ДЕЙСТВИЯ_ЗАПРОС:
        покажи preloader
    case ТИП_ДЕЙСТВИЯ_УСПЕШНО:
        скрой preloader, покажи данные
    case ТИП_ДЕЙСТВИЯ_НЕУДАЧНО:
        скрой preloader, покажи ошибку

Как нам известно, действие - это простой объект, который возвращается функцией его создающей (action creator).

Убедимся в этом:

src/actions/PageActions.js

import { SET_YEAR } from '../constants/Page'
export function setYear(year) {
  return {
    type: SET_YEAR,
    payload: year
  }
}

Было бы неплохо иметь возможность возвращать не простой объект, а функцию, внутри которой иметь доступ к методу dispatch, и вызывать его с необходимым типом действия. Псевдокод, мог бы выглядеть так:

export function getPhotos(year) {
  return (dispatch) => {
    dispatch({
      type: GET_PHOTOS_REQUEST
    })

    $.ajax(url)
      .success(
        dispatch({
          type: GET_PHOTOS_SUCCESS,
          payload: response.photos
        })
      )
      .error(
        dispatch({
          type: GET_PHOTOS_FAILURE,
          payload: response.error,
          error: true
        })
      )
  }
}

Но вот незадача, actions - это простой объект, и если action creator возвращает не простой объект, а функцию, то это как-то... Подождите! Ведь это именно то, что нам нужно: Если action creator возвращает не простой объект, а функцию - выполни ее, иначе если это простой объект ... тадам, передай дальше. Более того, благодаря applyMiddleware у нас как раз есть доступный метод dispatch! И еще бонусом getState.

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

усилитель: redux-thunk

function thunkMiddleware({ dispatch, getState }) {
  return next => action =>
    typeof action === 'function' ?
      action(dispatch, getState) :
      next(action);
}

module.exports = thunkMiddleware

Нам остается лишь добавить зависимость в наш проект, и убедиться, что у нас redux версии, не ниже 3.1.0

npm update redux --save
npm install redux-thunk --save

Для практики, предлагаю написать следующее:

  • по клику на кнопку с номером года

    • меняется год в заголовке

    • ниже (где должны быть фото), появляется текст "Загрузка..."

  • после удачной загрузки*

    • убрать текст "Загрузка..."

    • отобразить строку "У тебя ХХ фото" (зависит, от длины массива, переданного в action.payload)

* вместо реального метода загрузки, использовать setTimeout, который является удобным для тренировок исполнения асинхронных запросов.

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

Для отображения / скрытия фразы "Загрузка...", используйте в reducer'е еще одно свойство у состояния. Например, fetching:

const initialState = {
  year: 2016,
  photos: [],
  fetching: false
}

Решение ниже.

Для начала изменим набор констант:

src/constants/Page.js

export const GET_PHOTOS_REQUEST = 'GET_PHOTOS_REQUEST'
export const GET_PHOTOS_SUCCESS = 'GET_PHOTOS_SUCCESS'

Далее добавим новый усилитель: src/store/configureStore.js

import { createStore, applyMiddleware } from 'redux'
import rootReducer from '../reducers'
import createLogger from 'redux-logger'
import thunk from 'redux-thunk' // <-- добавили redux-thunk


export default function configureStore(initialState) {
  const logger = createLogger()
  const store = createStore(
    rootReducer,
    initialState,
    applyMiddleware(thunk, logger)) // <-- добавили его в цепочку перед logger'ом

  if (module.hot) {
    module.hot.accept('../reducers', () => {
      const nextRootReducer = require('../reducers')
      store.replaceReducer(nextRootReducer)
    })
  }

  return store
}

Изменим action creator: src/actions/PageActions.js

import {
  GET_PHOTOS_REQUEST,
  GET_PHOTOS_SUCCESS
} from '../constants/Page'

export function getPhotos(year) {

  return (dispatch) => {
    dispatch({
      type: GET_PHOTOS_REQUEST,
      payload: year
    })

    setTimeout(() => {
      dispatch({
        type: GET_PHOTOS_SUCCESS,
        payload: [1,2,3,4,5]
      })
    }, 1000)
  }
}

Изменим reducer: src/reducers/page.js

import {
  GET_PHOTOS_REQUEST,
  GET_PHOTOS_SUCCESS
} from '../constants/Page'

const initialState = {
  year: 2016,
  photos: [],
  fetching: false
}

export default function page(state = initialState, action) {

  switch (action.type) {
    case GET_PHOTOS_REQUEST:
      return { ...state, year: action.payload, fetching: true }

    case GET_PHOTOS_SUCCESS:
      return { ...state, photos: action.payload, fetching: false }

    default:
      return state;
  }

}

У нас готова логика для обновления состояния (и интерфейса, разумеется). Осталось поправить отображение.

Так как мы переписали и переименовали функцию (setYear -> getPhotos):

src/containers/App.js

...
    const { getPhotos } = this.props.pageActions

    return <div className='row'>
      <Page photos={page.photos} year={page.year} getPhotos={getPhotos} fetching={page.fetching}/>
...

Причем, в mapDispatchToProps - нам ничего менять не нужно, так как мы по прежнему присоединяем все pageActions в props контейнера <App />

Обновим соответствующий компонент: src/components/Page.js

import React, { PropTypes, Component } from 'react'

export default class Page extends Component {
  onYearBtnClick(e) {
    this.props.getPhotos(+e.target.innerText)
  }
  render() {
    const { year, photos, fetching } = this.props
    return <div className='ib page'>
      <p>
        <button className='btn' onClick={::this.onYearBtnClick}>2016</button>{' '}
        <button className='btn' onClick={::this.onYearBtnClick}>2015</button>{' '}
        <button className='btn' onClick={::this.onYearBtnClick}>2014</button>
      </p>
      <h3>{year} год</h3>
      {
        fetching ?
        <p>Загрузка...</p>
        :
        <p>У тебя {photos.length} фото.</p>
      }
    </div>
  }
}

Page.propTypes = {
  year: PropTypes.number.isRequired,
  photos: PropTypes.array.isRequired,
  getPhotos: PropTypes.func.isRequired
}

Когда будете проверять работу в браузере, обратите внимание на логгер. Он все так же работает и информативен.

Пока мы писали код для асинхронного запроса, мы НЕ нарушили главные принципы redux-приложения: 1. Мы всегда возвращали новое состояние (новый объект, смотрите src/reducers/page.js) 2. Мы строго следовали однонаправленному потоку данных в приложении: юзер кликнул - возникло действие - редьюсер изменил - компонент отобразил.

Итого: вы можете сами дописать наше приложение, чтобы оно взаимодействовало с VK, так как все что нужно, это добавить реальный асинхронный запрос (точнее парочку - для логина, и для получения фото). Ложку дегтя добавляет тот факт, что для этого потребуется создать в интерфейсе VK приложение, и выполнять наши запросы с реального сервера, так как VK.API не работает с localhost.

Об этом мы и поговорим в следующей главе.

Исходный код на данный момент.

Last updated