@aic/react-remote-data-provider

Remote data provider for get/post requests by redux/components

Usage no npm install needed!

<script type="module">
  import aicReactRemoteDataProvider from 'https://cdn.skypack.dev/@aic/react-remote-data-provider';
</script>

README

pipeline status coverage report

react-remote-data-provider

Гибкий react компонент для загрузки данных из API и хранения их в redux

Содержание

Установка

via npm:

npm i @aic/react-remote-data-provider

via yarn:

yarn add @aic/react-remote-data-provider

Подключение

Подключите remote-data-provider редьюсер к redux по ключу remoteData:

import { combineReducers } from 'redux'
import { reducer as remoteDataReducer } from '@aic/react-remote-data-provider'

const rootReducer = combineReducers({
    // ... остальные редьюсеры
    remoteData: remoteDataReducer
})

export default rootReducer

Базовый пример

remote-data-provider можно использовать 3 способами: хук, компонент или HOC:

Хук useRemoteData

import React from 'react'
import { useRemoteData } from '@aic/react-remote-data-provider'

export default function MyGreatComponent (props) {
    const {
        response, // последний ответ от сервера, если есть, иначе undefined
        request, // текущий запрос, аналогичный запросу из параметров
        error, // последняя ошибка, если есть - содержит поля `status`, `message` и `data`
        isEmpty, // Boolean флаг, указывает, есть ли ответ с сервера
        isAjax, // Boolean флаг, указывает, происходит ли загрузка данных
        isError, // Boolean флаг, указывает, произошла ли ошибка во время последнего запроса
        reload, // функция, при вызове которой происходит перезагрузка данных; возвращает Promise
        clear, // функция, при вызове которой очищаются данные из redux
        providerId // уникальный id провайдера
    } = useRemoteData({
        // уникальный ключ, по которому в redux будут лежать данные
        reducerKey: 'myGreatData',
        // параметры запроса, аналогичные axios
        // если какие-либо параметры меняются, происходит автоматическая перезагрузка данных
        request: {
            url: 'http://my.perfect.api.com/get/some/data/',
            params: {
              count: props.count
            }
        }
    }) // компонент будет перерендериваться каждый раз, когда меняется состояние загрузки
    
    if (isEmpty) return null
    
    return JSON.stringify(response)
}

Компонент RemoteDataProvider

import React from 'react'
import { RemoteDataProvider } from '@aic/react-remote-data-provider'

export default function MyGreatComponent (props) {
  return (
    <RemoteDataProvider {...{
        // параметры аналогичны `useRemoteData`
        reducerKey: 'myGreatData',
        request: {
            url: 'http://my.perfect.api.com/get/some/data/',
            params: {
                count: props.count
            }
        },
        
        // дополнительный параметр для `RemoteDataProvider`
        // если `true`, то children функция будет вызываться только тогда,
        // когда данные успешно загружены
        onlyWithData: true // такая запись не обязательна, т.к. по умолчанию `true`
    }}>
        {/* children функция, вызывается каждый раз при изменении состояния загрузки */}
        {({ // объект аналогичен результату `useRemoteData`
            response, request, error, isEmpty, isAjax, isError, reload, clear
        }) => {
            // если параметр `onlyWithData` не установлен в `false`,
            // проверять `isEmpty` нет необходимости
            return JSON.stringify(response)
        }}
    </RemoteDataProvider>
  )
}

HOC withRemoteData

import React from 'react'
import { withRemoteData } from '@aic/react-remote-data-provider'

// `withRemoteData` может принимать как функцию, которая принимает `props`
// и возвращает один (или массив, для нескольких запросов) объект с параметрами,
// так и просто объект (или несколько) с параметрами
export default withRemoteData(props => ({
    // параметры аналогичны `RemoteDataProvider`
    reducerKey: 'myGreatData',
    request: {
        url: 'http://my.perfect.api.com/get/some/data/',
        params: {
            count: props.count
        }
    },
    onlyWithData: true, // такая запись не обязательна, по умолчанию `true`
    
    // дополнительный параметр для `withRemoteData`
    // определяет ключ в `props`, где будут лежать данные
    // если не указан, данные будут объеденены с `props`
    stateKey: 'someData'
}))(MyGreatComponent)

function MyGreatComponent (props) {
    // данные лежат по ключу `someData`
    const { response, request, error, isEmpty, isAjax, isError, reload, clear } = props.someData
    // если параметр `onlyWithData` не установлен в `false`,
    // проверять `isEmpty` нет необходимости
    return JSON.stringify(response)
}

API

Объект данных

Данный объект содержит информацию о текущем состоянии данных и функции для работы с ним. Он возвращается хуком useRemoteData, передается как первый параметр children функции у компонента RemoteDataProvider, и передается в props с помощью HOC withRemoteData.

  • response: any: Ответ сервера, если запрос прошел успешно. Во время первой загрузки равен undefined. Во время последующих перезагрузок, когда меняется request или вызывается reload, предыдущий ответ сохраняется и доступен во время загрузки.
  • request: AxiosConfig: Параметры запроса. Соответствуют переданным параметрам в remote-data-provider, по этому редко используются.
  • error?: { status?: number, message?: string, data?: any }: Объект с ошибкой, если запрос закончился неудачно - при этом response, если он был, очищается. Если ошибки не произошло, равен undefined. Содержит (по возможности) поля status - статус (код) ответа сервера; message - сообщение ответа сервера; data - тело ответа сервера. Как только запрос начинается или заканчивается успешно, ошибка очищается.
  • isEmpty: boolean: Флаг, true когда response пустой - если данные ни разу не были загружены или произошла ошибка.
  • isAjax: boolean: Флаг, true когда происходит загрузка или обновление данных.
  • isError: boolean: Флаг, true когда произошла ошибка загрузки данных.
  • reload: () => Promise: Функция, которая перезагружает данные с последним request. Возвращает Promise загрузки.
  • clear: Function: Функция, которая очищает данные в redux. Не используйте ее, если remote-data-provider еще смонтирован, иначе загрузка начнется по новой.
  • providerId: string: Уникальный id для каждого инстанса remote-data-provider.

Общие параметры

Данные параметры имеют все вариации - хук useRemoteData, компонент RemoteDataProvider, HOC withRemoteData.

  • request: AxiosRequestConfig: remote-data-provider использует axios для выполнения запросов. request содержит параметры для axios, весь список находится здесь. Любые изменения в данном объекте (при перерисовке) приводят к перезагрузке данных - при этом происходит глубокое сравнение (_.equal). Не используйте функции в request.
  • requestFunctions?: AxiosRequestConfig: Аналогично request, только для функций из axios конфига. Изменения в данном объекте не приводят к перезагрузке данных.
  • reducerKey?: string: Все данные о состоянии и результате загрузки будут храниться в redux по ключу reducerKey. Обычно, это уникальное значение для каждого компонента. В случае, если reducerKey не указан, то он будет равен строковому значению request (для запроса из примера примерно "url='http://my.perfect.api.com/get/some/data'&params[count]=5" когда count равен 5).
  • reducerPath?: string | string[]: Параметр reducerPath, как и reducerKey, определяет место хранения в redux store. reducerPath определяет вложенность данных. Например, если reducerPath = ['some', 'path'], а reducerKey = 'someKey', то данные будут храниться в redux по пути remoteData.some.path.someKey.
  • axiosInstance?: AxiosInstance: экземпляр axios с предустановленными параметрами запроса. Создается с помощью axios.create({ ...params }).
  • disabled?: boolean: если true, то загрузки и обновления данных происходить не будет. Если request из запроса и из стора совпадают - то будут возвращены актуальные данные, иначе - пустой объект данных.
  • Могут быть дополнительные параметры, они все передаются в middlewares.

Хук useRemoteData

import { useRemoteData } from '@aic/react-remote-data-provider'

...

const remoteData = useRemoteData(params, requestDeps)

Хук загружает и обновляет данные, перерисовывая компонент при каждом изменении состояния. Возвращает объект данных.

  • params: object: Соответствуют общим параметрам.
  • requestDeps?: any[]: Зависимости, используемые в параметре request, аналогично второму аргументу для useMemo. Например, для запроса из примера
    (request = { url: '...', params: { count: props.count } }) в качестве requestDeps нужно передать [props.count]. Если request постоянный (не меняется), нужно передавать пустой массив ([]). При этом зависимости для остальных параметров, кроме request, передавать не нужно. Используется для улучшения производительности. Если вы не уверены, что нужно передавать как requestDeps, не используйте этот параметр.

Компонент RemoteDataProvider

import { RemoteDataProvider } from '@aic/react-remote-data-provider'

...

<RemoteDataProvider { ...params }>
    {(remoteData, props) => { ... }}
</RemoteDataProvider>

Компонент является оберткой над хуком useRemoteData. Вызывает children функцию при каждом изменении состояния данных.

  • params: object: В дополнение к общим параметрам может содержать:
    • onlyWithData?: boolean = true: Если true, children функция будет вызываться только тогда, когда есть данные (isEmpty === false). По умолчанию true.
    • requestDeps: any[]: соответствует второму аргументу хука useRemoteData.
  • children: function (remoteData: object, props: object): ReactNode: children функция, принимает первым аргументом объект данных, вторым - все props, переданные RemoteDataProvider.

По умолчанию, children функция вызывается только тогда, когда есть загруженные данные (response). Это удобно, и позволяет с уверенностью использовать response, однако, уменьшает гибкость. Для большего контроля, можно использовать параметр onlyWithData=false, при котором RemoteDataProvider будет вызывать children функцию каждый раз, когда меняется состояние загрузки.
Например:

import React from 'react'
import { RemoteDataProvider } from '@aic/react-remote-data-provider'

export const MyGreatComponent = (props) => (
    <RemoteDataProvider {...{
        reducerKey: 'myGreatData',
        request: {
            url: 'http://my.perfect.api.com/get/some/data/',
            params: {
                count: props.count
            }
        },
        onlyWithData: false // меняем параметр
    }}>
        {({ isAjax, isEmpty, isError, response, error }) => {
            if (isError) return `Упс, произошла ошибка ${error.status}`
            if (isEmpty) return `Загрузка...`
            
            let result = JSON.stringify(response)
            if (isAjax) result += ' новые данные уже близко...'
            return result
        }}
    </RemoteDataProvider>
)

HOC withRemoteData

import { withRemoteData } from '@aic/react-remote-data-provider'

...

withRemoteData(params, params, ...)(Component)
withRemoteData(props => params)(Component)
withRemoteData(props => [params, params, ...])(Component)

HOC оборачивает компонент, передавая в props объект(ы) данных. Может принимать:

  • Просто объекты с параметрами - один или несколько, как аргументы.

  • Функцию, возвращающую объект с параметрами.

  • Функцию, возвращающую массив с несколькими объектами с параметрами.

  • params: object: В дополнение к общим параметрам может содержать:

    • onlyWithData?: boolean = true: Если true, children функция будет вызываться только тогда, когда есть данные (isEmpty === false). По умолчанию true.
    • requestDeps: any[]: соответствует второму аргументу хука useRemoteData.
    • stateKey?: string | string[]: Ключ в props, в котором будет храниться объект данных. Если stateKey не указан, все ключи объекта данных будут записаны на прямую в props (props.isAjax, props.response, ...).
    • propsKey?: string | string[]: Ключ в props, в котором будет храниться объект со всеми параметрами RemoteDataProvider (второй получаемый аргумент в функции children при обычном использовании). Если propsKey не указан, параметры RDP не будут переданы в props.
    • provider?: RemoteDataProvider: Компонент, использованный как RemoteDataProvider. По умолчанию, это обычный RemoteDataProvider. Используйте параметр provider для установки кастомного RemoteDataProvider из расширений.
    • Все остальные параметры будут переданы на прямую в RemoteDataProvider, оборачивающий компонент, и будут работать как обычно.
  • Component: React.ComponentType: Оборачиваемый компонент.

Обратите внимание, если параметр onlyWithData в объектах данных задан как true (или не задан - он по умолчанию true), то компонент не будет смонтирован до того момента, как данные для всех объектов с onlyWithData == true не загрузятся.

Как это работает

В этом разделе описывается жизненный цикл данных с использованием remote-data-provider.

Начало загрузки

Изначально в redux никаких данных нет:

// redux state
{
    remoteData: {}
}

Используем наш компонент из базового примера:

<div>
    <MyGreatComponent count={1} />
</div>

useRemoteData начинает загружать данные из http://my.perfect.api.com/get/some/data?count=1 и записывает данные в redux:

// redux state
{
    remoteData: {
         myGreatData: { // параметр reducerKey='myGreatData'
             providerId: 's8dfj3nlsi',
             isAjax: true,
             isEmpty: true,
             isError: false,
             response: undefined,
             request: {
               url: 'http://my.perfect.api.com/get/some/data'
               params: {
                 count: 1
               }
             },
             error: undefined
         }  
    }
}

Пока данных нет - isEmpty === false - наш компонент возвращает null

// result
<div></div>

Успешная загрузка

Когда данные успешно загружены, они записываются в redux:

// redux state
{
    remoteData: {
        myGreatData: {
            isAjax: false,
            isEmpty: false,
            response: ['somedata'],
            // ... остальное без изменений
        }
    }
}

После чего происходит рендер с данными:

// result
<div>
    ["somedata"]
</div>

Неудавшаяся загрузка

Когда происходит ошибка загрузки данных, данные об ошибке записываются в redux:

// redux state
{
    remoteData: {
         myGreatData: {
             providerId: 's8dfj3nlsi',
             isAjax: false,
             isEmpty: true,
             isError: true,
             response: undefined,
             request: {
               url: 'http://my.perfect.api.com/get/some/data'
               params: {
                 count: 1
               }
             },
             error: {
                 status: 404, // статус ошибки, если есть
                 message: '404 Not Found', // сообщение об ошибке, если есть
                 data: {} // дополнительные данные (тело ответа), если есть
             }
         }  
    }
}

Так как при этом isEmpty === false, компонент возвращает null.

Повторный рендер

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

<div>
    <MyGreatComponent count={1} />
</div>
// result
<div>
    ["somedata"]
</div>

Изменение параметров

Если изменить параметр запроса count:

<div>
    <MyGreatComponent count={3} />
</div>

RemoteDataProvider сравнит объекты запросов из redux и props, увидит изменения и начнет перезагружать данные:

// redux state
{
    remoteData: {
        myGreatData: {
            isAjax: true, // началась загрузка
            request: {
                url: 'http://my.perfect.api.com/get/some/data'
                params: {
                    count: 3 // изменившийся параметр
                }
            },
            // ... остальное без изменений
        } 
    }
}

При этом старые данные все еще будут доступны, и компонент сможет их использовать:

// result
<div>
    ["somedata"] новые данные уже близко...
</div>

Успешная загрузка новых данных

Когда новые данные будут загружены, они будут записаны в redux:

// redux state
{
    remoteData: {
        myGreatData: {
            isAjax: false,
            response: ['somedata', 'somedata', 'somedata'],
            // ... остальное без изменений
        }
    }
}

И компонент будет рендериться с новыми данными:

// result
<div>
    ["somedata", "somedata", "somedata"]
</div>

Неудавшаяся загрузка новых данных

Если не удалось загрузить новые данные, то данные об ошибке записываются, а старые данные удаляются:

// redux state
{
    remoteData: {
         myGreatData: {
             providerId: 's8dfj3nlsi',
             isAjax: false,
             isEmpty: true,
             isError: true,
             response: undefined,
             request: {
               url: 'http://my.perfect.api.com/get/some/data',
               params: {
                 count: 1
               }
             },
             error: {
                 status: 404, // статус ошибки, если есть
                 message: '404 Not Found', // сообщение об ошибке, если есть
                 data: {} // дополнительные данные об ошибке, если есть
             }
         }  
    }
}

Так как при этом isEmpty === false, компонент возвращает null.

Композиция провайдеров Composer

Если необходимо для нескольких загружаемых данных иметь одну children функцию, используйте Composer. Параметры компонента:

  • providers: Object | Array - объект или список, содержащий готовые элементы или параметры (объектов) для провайдера.
  • defaultProvider?: RemoteDataProvider - провайдер, использующийся для параметров (объектов) в providers. По умолчанию обычный RemoteDataProvider. Можно использовать кастомизированные провайдеры из расширений.
import React from 'react'
import { RemoteDataProvider, Composer } from '@aic/react-remote-data-provider'

// ...

<Composer
    providers={{
        someData: <RemoteDataProvider {...someDataOptions} />, // элемент
        otherData: { request, onlyWithData, ... }, // параметры для RDP
    }}
>
    {({ someData, otherData }) => {
        console.log(someData) // => { isEmpty, response, ... }
        console.log(otherData) // => { isEmpty, response, ... }
    }}
</Composer>

Вместо объекта в данном примере можно использовать массив в качестве параметра providers, тогда children функция должна принимать массив, порядок объектов данных которого будет соответствовать порядку в providers.

Особенности

  • Composer начинает загружать все данные одновременно,
  • children функция вызывается при каждом изменении, после того, как у всех провайдеров с параметром onlyWithData === true имеются загруженные данные.

Доступ к данным в redux

Время от времени появляется необходимость получить информацию о загружаемых данных вне remote-data-provider. Для этого можно использовать стандартный декоратор connect из redux, получая данные с помощью функции getLocalState или хук useLocalState.

Функция getLocalState

import { connect } from 'react-redux'
import { getLocalState } from '@aic/react-remote-data-provider'

function mapStateToProps (state) {
  const reducerKey = 'someKey'
  const reducerPath = ['some', 'path']
  return {
    someDataState: getLocalState(state, reducerKey, reducerPath)
  }
}

@connect(mapStateToProps)
export class SomeReactComponent extends React.Component {
    // ...
}

(подробная информация про декоратор connect в официальном репозитории)
Функция getLocalState принимает следующие параметры:

  • state - redux state;
  • reducerKey: Такой же, как в remote-data-provider, который загружает эти данные;
  • reducerPath: Такой же, как в remote-data-provider, который загружает эти данные, может отсутствовать.

Функция возвращает данные, находящиеся по указанным reducerPath и reducerKey. Если ничего не найдено, функция возвращает пустой объект данных:

{
   providerId: undefined,
   response: undefined,
   request: undefined,
   isEmpty: true,
   isAjax: false,
   isError: false,
   error: undefined
}

Хук useLocalState

Хук работает аналогично getLocalState в связке с connect, но без необходимости передавать state.

import { useLocalState } from '@aic/react-remote-data-provider'

export function SomeReactComponent (props) {
    const reducerKey = 'someKey'
    const reducerPath = ['some', 'path']
    const someDataState = useLocalState(reducerKey, reducerPath)
    ...
}

Предзагрузка данных

Пакет remote-data-provider предоставляет два действия:

  • getData(props): function (dispatch): Promise - принимает props - все параметры RemoteDataProvider, и возвращает функцию, которая загружает данные, записывая в redux информацию о начале загрузке, и в последствии об успешной или неудавшейся загрузке. Возвращает Promise.
  • clearData(props): function (dispatch): void - принимает props - все параметры RemoteDataProvider, и возвращает функцию, которая очищает состояние в state.

Данные действия используются непосредственно в useRemoteData, по этому, чтобы вызывать их вне компонента, используйте функцию getRDPStaticProps, которая возвращает все внутренние параметры useRemoteData. Функция getRDPStaticProps принимает:

  • state: redux state.
  • props: Внешние параметры для useRemoteData.

Например, мы используем RemoteDataProvider со следующими параметрами:

const someDataOptions = {
    reducerKey: 'someKey',
    request: {
        url: '/some/url'
    }
}

return (
    <RemoteDataProvider {...someDataOptions}>
        {(remoteData) => { ... }}
    </RemoteDataProvider>
)

Предзагрузка данных для этого компонента:

import { getData, getRDPStaticProps } from '@aic/react-remote-data-provider'
import { store } from 'src/store' // ваш redux store

const state = store.getState()

const staticSomeDataProps = getRDPStaticProps(
    state,
    someDataOptions // RDP параметры из примера выше
)

const loadingPromise = getData(staticSomeDataProps)(store.dispatch) // => Promise
loadingPromise.then(() => {
    console.log('Загрузка данных окончена') // данные сохраннены в redux
}) 

Для получения store и dispatch в компоненте, можно использовать хуки useStore и useDispatch из redux:

import React, { useEffect } from 'react'
import { useStore, useDispatch } from 'react-redux'
import { getData, getRDPStaticProps } from '@aic/react-remote-data-provider'

function SomeComponent (props) {
  const store = useStore()
  const dispatch = useDispatch()

  useEffect(() => {
    const staticSomeDataProps = getRDPStaticProps(
      store.getState(),
      someDataOptions // RDP параметры из примера выше
    )
    const loadingPromise = getData(staticSomeDataProps)(dispatch) // => Promise
    loadingPromise.then(() => console.log('Загрузка данных окончена'))
  }, []) // загружаем данные один раз

  // ...
}

Предзагрузка данных ведет себя так же, как если бы данные загружал RemoteDataProvider. Данные записываются в redux, отрабатывают все middleware, и т.д.
Когда вы используете RemoteDataProvider с параметрами someDataOptions (из примера), у него уже будут все данные, и он не будет повторно их загружать.

Подробнее про действие getData в API reference

Расширения для remote-data-provider

Расширения позволяют увеличить гибкость и базовый функционал remote-data-provider. Обычно, расширения подключаются с помощью redux middleware, и могут предоставлять компоненты-обертки над стандартным RemoteDataProvider. Пакет remote-data-provider предоставляет несколько расширений. Те расширения, которые вы не используете, не попадают в финальную сборку вашего приложения.
Вы также можете сами создавать расширения для remote-data-provider.

Расширение collector

Данное расширение позволяет реализовывать подзагрузку данных, совмещая старые и новые данные.

Предназначение

Нередко возникает потребность разделить данные, получаемые из API, на части, и в последствии догружать их. Расширение collector упрощает дозагрузку и совмещение данных для таких случаев.

Подключение

Подключите collectorMiddleware к вашему store как middleware

// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import { collectorMiddleware } from '@aic/react-remote-data-provider/extensions/collector'

export default function configureStore () {

    // ...
   
    const store = compose(
        applyMiddleware(collectorMiddleware())
        // ...
    )(createStore)(rootReducer, initialState)
    
    return store
}

Подробнее про collectorMiddleware в API reference

Компонент RemoteDataProviderCollector

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

  • changeableRequest: object - часть обычного request, которую можно впоследствии изменять, при этом данные будут собираться вместе; обязательный параметр.
  • path?: Array<string> | string - путь в response, в котором будут собираться данные. Если не указан, данные будут собираться в корне response.
  • unshift?: boolean - если true, новые данные будут добавляться перед старыми (в начало), иначе - наоборот (по умолчанию false).

При этом, RemoteDataProviderCollector передает во второй аргумент children функции метод setChangeableRequest, позволяющий устанавливать новый changeableRequest. Пример использования:

import { RemoteDataProviderCollector } from '@aic/react-remote-data-provider/extensions/collector'

const props = {
reducerKey: 'someKey',
request: {
  url: '/api/users',
  params: {
   permission: 'admin'
  }
},
changeableRequest: { // начальный changeableRequest
 params: {
   page: 1
 }
},
path: 'user',
// остальные RemoteDataProvider props
}

<RemoteDataProviderCollector {...props}>
  {({ response, request }, { setChangeableRequest }) => {
   console.log('response:', response)
   console.log('request:', request)
   const loadNextPage = () => setChangeableRequest({
     params: {
       page: request.params.page + 1
     }
   })
   return <button onClick={loadNextPage}>load next page</button>
  }}
</RemoteDataProviderCollector>

// выведет в консоль:
// 'response:'
{
 user: [ // данные собираются тут (path === 'user')
   { /* некоторая информация о пользователе со страницы 1 */ }
 ],
 // остальные данные ответа только со страницы 1
}
// 'request:'
{
  url: '/api/users',
  params: {
   permission: 'admin',
   page: 1 // добавлено из `changeableRequest`
  }
}
// выведет в консоль после нажатия на кнопку 'load next page' и дозагрузки страницы 2:
// 'response:'
{
 user: [
   { /* некоторая информация о пользователе со страницы 1 */ },
   { /* некоторая информация о пользователе со страницы 2 */ }
 ],
 // остальные данные ответа только со страницы 2
}
// 'request:'
{
  url: '/api/users',
  params: {
   permission: 'admin',
   page: 2 // добавлено из `changeableRequest`
  }
}

Особенности

  • Параметр changeableRequest задает только начальные изменяющиеся параметры запроса. После использования метода setChangeableRequest в request попадают только новые установленные параметры.
  • Используемый changeableRequest является частью конечного request, но также его можно получить во втором параметре children функции:
    ({ request, ... }, { setChangeableRequest, changeableRequest }) => { ... }
  • Аргумент метода setChangeableRequest полностью заменяет changeableRequest, дублируйте параметры, даже если они не изменились. Например:
    setChangeableRequest({ ...changeableRequest, someParam: 'new value' })
  • Для корректной работы, ключи объекта changeableRequest не должны меняться. Если какой-то параметр изначально не используется, но в будущем изменится, задайте его undefined:
    changeableRequest={{ params: { someParam: 'value', otherParam: undefined } }}
  • После того, как компонент отмонтируется (например, вы перейдете на другую страницу), а потом обратно примонтируется (вернетесь обратно на предыдущую страницу) - RemoteDataProviderCollector восстановит последний changeableRequest из redux state. Для полного восстановления, необходимо, чтобы ключи changeableRequest не менялись (см. предыдущий пункт).
  • Если данные по ключу path в response - не массив, то они оборачиваются в массив. Например, если path === 'user', а response === { user: { name: 'Dan', ... } } - response преобразуется в { user: [{ name: 'Dan', ... }] }. Если в текущем примере path не задан, response преобразуется в [{ user: { name: 'Dan', ... } }]

Подробнее про RemoteDataProviderCollector в API reference

Расширенное использование

collectorMiddleware добавляет несколько дополнительных параметров для обычного RemoteDataProvider:

  • exCollectorChangeableRequest?: Array< string | Array<string> > - массив ключей в request, которые в нем могут меняться от запроса к запросу. При новой загрузке данных, если есть предыдущий результат, и в request поменялись значения только у ключей данного параметра, старые данные будут добавлены к новым. Иначе в redux будут записаны только новые данные. Если данный параметр не указан, все будет работать как обычно.
    Ключи могут быть строками ('param', 'some.deep.param', 'some.array[0]') или массивами (['param'], ['some', 'deep', 'param'], ['some', 'array', '0']).
  • exCollectorPath?: Array<string> | string - путь в response, в котором будут собираться данные. Если не указан, данные будут собираться в корне response.
    Если данные по собираемому пути - не массив, то они оборачиваются в массив. Например, если exCollectorPath === 'user', а response === { user: { name: 'Dan', ... } } - response преобразуется в { user: [{ name: 'Dan', ... }] }. Если в текущем примере exCollectorPath не задан, response преобразуется в [{ user: { name: 'Dan', ... } }].
    Данный параметр работет, только если указан exCollectorChangeableRequest, иначе игнорируется и преобразования не происходит.
  • exCollectorUnshift?: boolean - если true, новые данные будут добавляться перед старыми (в начало), иначе - наоборот (по умолчанию false).

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

Расширение globalError

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

Предназначение

Хранение ошибок в одном месте упрощает ведение статистики, выдачу окна ошибки или страницы, описывающей ошибку.

Подключение

Подключите globalErrorMiddleware к вашему store как middleware. Данная функция принимает один не обязательный параметр - объект, содержащий настройки для middleware:

  • reduxKey?: string = '_globalError' - этот параметр определяет место храния ошибок в redux. Ошибки хранятся внутри remoteData, по этому, вы не можете использовать значение этого параметра как параметр reducerKey в компоненте RemoteDataProvider. По умолчанию он равен '_globalError'. Меняйте этот параметр, только если есть пересечения в названиях.
  • setErrorKey?: string = 'exGlobalError' - ключ для параметра компонента RemoteDataProvider. Меняйте этот параметр, если у вас подключено несколько globalErrorMiddleware, или этот ключ пересекается с другим параметром. Далее в описании будет использоваться стандартное значение - 'exGlobalError'
  • setByDefault?: boolean = false - этот параметр определяет exGlobalError (может называться иначе, если изменен setErrorKey) по умолчанию, если он не определен в параметрах RemoteDataProvider компонента.
// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import { collectorMiddleware } from '@aic/react-remote-data-provider/extensions/globalError'

export default function configureStore () {

    // ...
    
    const store = compose(
        applyMiddleware(
            globalErrorMiddleware(), // без параметров
            // или
            globalErrorMiddleware({ // с параметрами
                reduxKey: '_globalError',
                setByDefault: false
            })
        )
        // ...
    )(createStore)(rootReducer, initialState)
    
    return store
}

Подробнее про globalErrorMiddleware в API reference

Использование

globalErrorMiddleware добавляет для компонента RemoteDataProvider параметр exGlobalError (может называться иначе, если установлено значение setErrorKey в настройках middleware). Если данный параметр равен true, то ошибки загрузки, вызванные этим компонентом, будут собираться в глобальном хранилище redux.

   <RemoteDataProvider
       // ... обычные параметры
       exGlobalError 
   /> // ошибки будут собираться в глобальном хранилище
   
   <RemoteDataProvider
       // ... обычные параметры
   /> // ошибки НЕ будут собираться в глобальном хранилище

Если вы установили значение setByDefault === true в настройках globalErrorMiddleware, то по умолчанию ошибки будут записываться в глобальное хранилище с любого RemoteDataProvider. Чтобы не собирать ошибки для отдельного компонента, установите значение exGlobalError в false:

   <RemoteDataProvider
       // ... обычные параметры
   /> // ошибки будут собираться в глобальном хранилище
   
   <RemoteDataProvider
       // ... обычные параметры
       exGlobalError={false}
   /> // ошибки НЕ будут собираться в глобальном хранилище

Получение и очистка ошибок

Для получения ошибок из глобального хранилища, используйте функции getGlobalErrors и hasGlobalError. Обе функции имеют одинаковые аргументы:

  1. state - redux state
  2. reduxKey = '_globalError' - должен быть аналогичным значению reduxKey из настроек globalErrorMiddleware. Если вы не устанавливали данную настройку для middleware, не используйте этот параметр.

Функция getGlobalErrors возвращает массив собранных ошибок, каждая из которых имеет такой формат:

{
    reducerKey: string,
    reducerPath?: string,
    error: {
        status?: number,
        message?: string,
        data?: any
    }
}

reducerKey и reducerPath равны соответствующим параметрам компонента RemoteDataProvider, в котором произошла ошибка.

Функция hasGlobalError возвращает true, если есть хотя бы одна ошибка в глобальном хранилище, иначе false.

Для очистки глобального хранилища от всех ошибок, используйте функцию clearGlobalError, которая создает действие. Используйте его в dispatch, и глобальное хранилище очистится.

import { connect } from 'react-redux'
import { hasGlobalError, getGlobalErrors, clearGlobalError } from '@aic/react-remote-data-provider/extensions/globalError'

function mapStateToProps (state) {
  return {
    isGlobalError: hasGlobalError(state),
    globalErrors: getGlobalErrors(state)
  }
}

function mapDispatchToProps (dispatch) {
    clearGlobalError: () => dispatch(clearGlobalError())
}

@connect(mapStateToProps, mapDispatchToProps)
export class AppErrorWrapper extends React.Component {
    
    render () {
        const { isGlobalError, globalErrors, clearGlobalError } = this.props
        console.log(isGlobalError) // => true
        console.log(globalErrors) // => [{ reducerKey: 'someKey', error: { status: 404 } }]
        if (isGlobalError) {
            return (
                <div>
                    <p>Упс, что-то пошло не так...</p>
                    <p>Ошибок на странице: {globalErrors.length}</p>
                    <button onClick={clearGlobalError}>
                        попробовать еще раз
                    </button>
                </div>
            )
        } else {
            return <App />
        }
    }
}

Подробнее про расширение globalError в API reference

Расширение serverReRender

Данное расширение позволяет организовывать асинхронные действия во время SSR (Server Side Render), включая загрузку данных с помощью RemoteDataProvider.

Предназначение

react-dom/server при рендере сам по себе не позволяет производить асинхронные действия, которые часто требуются для загрузки данных и правильного отображения страницы. Это расширение позволяет проводить асинхронные операции между синхронными рендерами react-dom/server.

Подключение

Подключите asyncActionMiddleware к вашему store как middleware. Данная функция принимает один обязательный параметр - экземпляр класса AsyncActionController (будет рассмотрен позже).

// configureStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import { asyncActionMiddleware } from '@aic/react-remote-data-provider/extensions/serverReRender'

export default function configureStore (asyncActionController) { // принимаем контроллер извне

    // ...
   
    const store = compose(
        applyMiddleware(asyncActionMiddleware(asyncActionController))
        // ...
    )(createStore)(rootReducer, initialState)
    
    return store
}

Использование serverReRender

asyncActionMiddleware позволяет отслеживать загрузки данных внутри компонентов RemoteDataProvider с помощью AsyncActionController. Теперь, для перерисовок между загрузками данных, необходимо использовать функцию serverReRender(options: object): Promise<object>. Возможные параметры аргумента options:

  • maxRenders?: number = 10 - максимальное количество перерисовок, по умолчанию 10.
  • renderTimeout?: number = 5000 - максимальное время (в миллисекундах) для загрузки данных во время каждой отдельной перерисовки, по умолчанию 5 секунд.
  • controller: AsyncActionController - экземаляр класса AsyncActionController, должен быть тем же самым экземпляром, который использован в asyncActionMiddleware.
  • render: (next, stop) - функция рендера, вызывающаяся при каждой перерисовке. Принимает аргументами две функции, одну из которых вы обязаны вызвать:
    • next(result: any): void - заканчивает данную перерисовку с результатом рендера.
    • stop(result?: any): void - заканчивает все перерисовки. Если указан result, он заменяет собой последний результат, если не указан - используется предыдущий результат (при последнем вызове функции next).

Функция возвращает промис (Promise), который всегда выполняется успешно, и содержащий объект со следующими полями:

  • result: any - аргумент, переданный в функцию next при последнем ее вызове. Если была использована функция stop с указанным аргументом result, будет использован он.
  • error?: SSRRenderCountError | SSRTimeoutError | any - ошибка, произошедшая во время перерисовок. Если ошибок не произошло, будет равен undefined. Ошибка может произойти в следующих случаях:
    • Произошла ошибка во время выполнения функции render.
    • Превышено максимальное количество перерисовок (параметр maxRenders) - тогда ошибка будет создана с помощью SSRRenderCountError.
    • Превышено максимальное время ожидания загрузки (параметр renderTimeout) - тогда ошибка будет создана с помощью SSRTimeoutError.
  • stats: { renders: number, actions: number } - статистика, содержащая общее количество перерисовок (renders) и выполненных асинхронных действий (actions).

Пример использования с express и react-router-dom:

// server.js

import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter as Router } from 'react-router-dom'
import configureStore from 'services/store/configureStore' // ваш configuireStore
import { Provider } from 'react-redux'
import { AsyncActionController, serverReRender, SSRRenderCountError, SSRTimeoutError } from '@aic/react-remote-data-provider/extensions/serverReRender'
import App from '../src/App' // компонент для рендера приложения
import App from '../src/Html' // компонент для рендера HTML 

const app = express()

app.use((req, res) => {
  // создаем экземпляр AsyncActionController
  const asyncActionController = new AsyncActionController()
  // передаем экземпляр AsyncActionController для asyncActionMiddleware при создании redux store
  const store = configureStore(asyncActionController)
  // контекст для static router (см. пакет 'react-router-dom')
  const context = {}

  serverReRender({
    maxRenders: 10,
    renderTimeout: 5000
    controller: asyncActionController,
    render: (next, stop) => { // функция, вызывающаяся при каждой перерисовке
      // прерываем перерисовки, если происходит редирект
      if (context.url) {
        return stop()
      }
      return next( // передаем результат на каждой итерации
        renderToString(
          <Provider store={store}> // store единый для всех перерисовок
            <Router context={context} location={req.url}>
              <App />
            </Router>
          </Provider>
        )
      )
    }
  }).then(({ result, error, stats }) => {
    if (context.url) { // производим редирект, если он есть
      res.redirect(301, context.url)
      return
    }
    if (error) { // если имеется ошибка во время рендера
      if (error instanceof SSRRenderCountError) {
        console.error('Количество перерисовок превысило максимум')
      } else if (error instanceof SSRTimeoutError) {
        console.error('Превышено максимальное время ожидания между перерисовками')
      }
      const HTML = <Html result={renderToString(<h1>Ошибка!</h1>)} />
      res.status(500).send(HTML)
      return
    }
    // успешный рендер HTML
    const HTML = renderToString(<Html result={result} state={store.getState()} />)
    res.status(200).send(HTML)
  })
})

После всех перерисовок все загруженные данные будут храниться в redux store. Для корректной перерисовки в браузере, необходимо передать вместе с HTML состояние redux store. Подробнее об этом описано тут.

Как это работает

Каждая итерация перерисовки происходит следующим образом:

  • Происходит рендер главного компонента.
    • Если во время рендера используются компоненты RemoteDataProvider, они начинают загрузку данных в redux store (если данных еще нет).
    • Когда начинается (и заканчивается) загрузка данных в RemoteDataProvider, asyncActionMiddleware начинает (и завершает при окончании) асинхронное действие в контроллере - экземпляре класса AsyncActionController.
  • После рендера, serverReRender, проверяет, имеются ли активные асинхронные действия (загрузки) в контроллере. Если они есть, происходит ожидание окончания всех загрузок.
  • Если во время рендера происходили асинхронные действия, после ожидания начинается новая перерисовка. Иначе перерисовки заканчиваются, и промис выполняется с результатом.

Перерисовки заканчиваются преждевременно (вне зависимости от асинхронных действий), если происходит ошибка (передаваемый параметр error).
Так как все данные о загрузках сохраняются в redux store, у компонентов RemoteDataProvider появляются данные, и повторной перезагрузки на каждый рендер не происходит.
Вложенные друг в друга RemoteDataProvider компоненты учитываются (для них происходит загрузка данных), однако увеличивается количество перерисовок, если вы не монтируете вложенные RemoteDataProvider при отсутствии данных у верхнего компонента.

Расширенное использование

Вы можете использовать экземпляр класса AsyncActionController самостоятельно, для добавления асинхронных действий во время перерисовок. Действия, добавленные самостоятельно, точно также учитываются во время перерисовок, как и RemoteDataProvider.

Класс AsyncActionController

В основе расширения используется класс AsyncActionController, помогающий отслеживать начало и завершение асинхронных событий. Каждое асинхронное действие должно иметь уникальный id. API класса:

  • new AsyncActionController(options?: object) - конструктор класса. options - не обязательный аргумент, и может содержать следующие параметры:
    • maxAsyncActionDuration?: number = 0 - максимальная длительность (в миллисекундах) каждого асинхронного действия, после чего оно будет автоматически завершено с ошибкой. Если установлено в 0 (по умолчанию), длительность не ограничена.
    • serverOnly?: boolean = true - если true (по умолчанию), будет отслеживать асинхронные действия только на сервере, на клиенте все асинхронные действия будут игнорироваться.
  • isDisabled(): boolean - возвращает true, если контроллер не отслеживает асинхронные действия (параметр конструктора serverOnly === true и код выполняется в браузере).
  • startAction(id: string | number): void - начинает асинхронное действие с данным id. Если действие уже было начато (или завершено), ничего не происходит.
  • completeAction(id: string | number, error?: any): void - заканчивает асинхронное действие с данным id. Если указан error, он записывается, как ошибка данного действия.
  • completeAllActions(): void - заканчивает все асинхронные действия. Используйте эту функцию, только если точно знаете, что все загрузки окончены. Обычно в ней нет необходимости.
  • createActionInstance(id: string | number): { start, complete } - создает независимый от класса экземпляр асинхронного действия, привязанного к данному id. Фактически, это синтаксический сахар для передачи функций startAction и completeAction независимо от класса и привязанными к id:
    • start(): void - привязка функции () => this.startAction(id)
    • complete(error?: any): void - привязка функции (error) => this.completeAction(id, error)
  • getCompletePromise(): Promise<void> - возвращает промис, который выполняется, когда все асинхронные действия будут окончены.
    Если до выполнения данного промиса будут добавлены новые асинхронные действия, они будут учтены в данном промисе (рекурсивно).
    Этот промис всегда выполняется успешно (resolve). Если isDisabled() === true, промис всегда будет сразу выполнен.
  • getActionPromise(id: string | number): Promise<void> - возвращает промис, который выполняется, когда заканчивается действие с данным id. Если действия с таким id не существует, или оно завершено, промис будет сразу выполнен (Promise.resolve()).
    Этот промис всегда выполняется успешно (resolve). Если isDisabled() === true, промис всегда будет сразу выполнен.
  • hasAction(id: string | number): boolean - возвращает true, если существует асинхронное действие с таким id.
    Если isDisabled() === true, всегда возвращает false.
  • hasIncompleteActions(): boolean - возвращает true, если остались не завершенные действия.
    Если isDisabled() === true, всегда возвращает false.
Пример использования в компонентах

Если необходимо совершить какое-либо асинхронное действие в компоненте, используйте AsyncActionProvider и withAsyncAction для передачи и получения контроллера соответственно.

// index.js
import { AsyncActionProvider } from '@aic/react-remote-data-provider/extensions/serverReRender'

...
// asyncActionController - экземплят AsyncActionController
<AsyncActionProvider controller={asyncActionController}>
  <App />
</AsyncActionProvider>
// SomeComponent.js
import React, { Component } from 'react'
import { withAsyncAction } from '@aic/react-remote-data-provider/extensions/serverReRender'
import { connect } from 'react-redux'

@connect(
  state => ({ someData: state.someData }),
  dispatch => ({ saveSomeData: payload => dispatch({ type: 'SAVE_SOME_DATA', payload }) })
)
@withAsyncAction('controller') // ключ в props с контроллером, по умолчанию 'asyncActionController'
class SomeComponent extends Component {
  id = 'SomeComponentId' // уникальный id загрузки для контроллера

  componentWillMount () {
    if (!this.props.someData) {
      this.props.controller.startAction(this.id)
      this.loadSomeData().then(data => {
        this.saveSomeData(data)
        this.props.controller.completeAction(this.id)
      })
    }
  }
  
  loadSomeData () {
    return new Promise(resolve => {
      // ... load some data here
    })
  }
}
Пример использования в действиях (с redux-thunk)

Пакет redux-thunk позволяет "пробрасывать" в redux действия дополнительный аргумент. Воспользуемся этим, чтобы использовать экземпляр AsyncActionController в redux действиях.

// configuireStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import { asyncActionMiddleware } from '@aic/react-remote-data-provider/extensions/serverReRender'
import thunkMiddleware from 'redux-thunk'

export default function configureStore (asyncActionController) { // принимаем контроллер извне

    // ...
   
    const store = compose(
        applyMiddleware(asyncActionMiddleware(asyncActionController)),
        applyMiddleware(
            // добавляем экстра аргумент для redux-thunk
            thunkMiddleware.withExtraArgument(asyncActionController)
        )
        // ...
    )(createStore)(rootReducer, initialState)
    
    return store
}

Теперь, можно использовать контроллер в redux действиях.

// loadSomeDataAction.js

export function loadSomeDataActionCreator () {
  return (dispatch, getState, actionController) => {
    // уникальный id действия для контроллера, можно генерировать
    const id = 'loadSomeDataAction_id'
    
    actionController.startAction(id) // начинаем асинхронное действие
    dispatch({
      type: 'LOAD_SOME_DATA_START'
    })
    
    axios.get('/some/data').then(response => {
      dispatch({
        type: 'LOAD_SOME_DATA_END',
        payload: response.data
      })
      asyncAction.completeAction(id) // заканчиваем асинхронное действие
    })
  }
}