redux-rest-actions
v1.1.7
Published
Redux middleware with simple configuration for handling REST requests with minimal code.
Downloads
55
Readme
redux-rest-actions
Redux middleware with simple configuration for handling REST requests with minimal code.
To install:
npm install --save redux-rest-actions
or
yarn add redux-rest-actions
Description
One of the primary functions that React / Redux apps need to deal with is transferring data between REST endpoints and the application's redux state. While this is straightforward with middleware like redux-thunk
, it still requires developers to write and test async actions. This middleware removes the need to write or test any asynchronous actions for REST API requests.
Features:
- Declarative structure. All REST API request URLs, methods, actions, and options configured in one place.
- No need to write or test async actions or use thunks or sagas for API requests. The middleware dispatches synchronous actions with REST API results.
- Uses Flux Standard Actions to provide response data in the
payload
property and request/response params in themeta
property. - Uses Axios and provides full control of the global and per-request configuration in a very simple way.
- Option to use
axios-mock-adapter
for development. - Provides a useful default behavior and options for handling overlapping requests, including debouncing.
- Provides a simple mechanism for request cancelation (ignoring a previous request).
Quick Start
There are three methods exported: configureApiMiddleware
to access the Redux middleware, configureApi
to configure your REST API, and api
to initiate (and cancel) API requests.
This is a full example showing how to render a TODO list in your UI.
- Add the middleware to Redux:
import { createStore, applyMiddleware } from 'redux';
import { configureApiMiddleware } from 'redux-rest-actions';
import todos from './reducers'
const apiMiddleware = configureApiMiddleware();
// include with your other middleware
const store = createStore(todos, [], applyMiddleware(apiMiddleware,...));
- Create your actions and reducer as usual. This example uses redux-actions:
import { createAction, handleActions } from 'redux-actions';
export const getTodos = createAction('GET_TODOS'); // request action
export const getTodosComplete = createAction('GET_TODOS_COMPLETE'); // success action
const reducer = handleActions({
[getTodos]: (action, state) => ({...state, pending: true}),
[getTodosComplete]: (action, state) => ({todos: action.payload}),
{todos: []}
});
export default reducer;
- Configure the API to define the REST endpoints and actions to dispatch:
import {configureApi} from 'redux-rest-actions';
import {getTodos, getTodosComplete} from './todos';
const store = configureStore();
// store is used to dispatch actions, and also allows you to provide request
// params from current state as described below.
configureApi(store, {
getTodos: {
url: '/api/todos',
actions: [getTodos, getTodosComplete] // can also add error/canceled actions
}
});
- Initiate REST requests in your UI components:
// Inside UI components, request data by invoking methods on "api". The container
// maps state.todos -> todos. Methods on api are already bound to dispatch:
import { api } from 'redux-rest-actions';
<TodosContainer>
<TodosView todos={todos} fetchTodos={api.getTodos} cancel={api.getTodos.cancel}>
</TodosContainer>
Configuring API Actions
Each property in the API config defines a request action, and adds a property with the same name to api
(e.g. api.getTodos
). In the actions
parameter, you define the actions to dispatch, the first two being required:
// API Config
{
requestAction: {
...
actions: [requestAction, successAction, errorAction, canceledAction]
}
}
Actions can be action creator functions
or action type strings
.
Using Action Creator Functions
If your actions are functions, invoking api.requestAction
will do the following:
Invoke your
requestAction
with whatever arguments are passed toapi.requestAction
, and check the returnedaction.payload
andaction.meta
properties to determine values for url placeholders, query parameters, post/put data, and any other per-request parameters, as described below. There is also a way to get request parameters from your reduxstate
.Generate a request config, and sends the REST request after checking any
overlappingRequests
ordebounce
options.Dispatch
requestAction
.Dispatch
successAction
orerrorAction
with API results or errors.
Your successAction
is invoked as successAction(response.data, response)
. The second argument is provided in case you want to access anything from the full response.
NOTE: The response.config
object has all non-serializable properties (functions and promises) removed.
If an error occurs, your errorAction
will be invoked with the Error
that was thrown. The error response is described here. If error.response
is not defined it means no response was received from the server.
If api.requestAction.cancel(reason)
is invoked while a request is pending, the results will be ignored and a canceledAction
, if defined, will be invoked with reason
and dispatched.
Using Action Type Strings
If you specify actions as type
strings, like GET_TODOS
, GET_TODOS_COMPLETE
, GET_TODOS_ERROR
, they will be dispatched as:
// requestAction
{type: 'GET_TODOS'}
// successAction
{type: 'GET_TODOS_COMPLETE', payload: response.data, meta: response}
// errorAction
{type: 'GET_TODOS_ERROR', payload: error, meta: response, error: true}
Providing Request Parameters and URL Placeholders
The most common per-request parameters needed are URL placeholders
, query params
, and data
for POST, PUT or PATCH requests.
Using Action Creators
If requestAction
is an action creator function
, the default behavior is as follows:
- It is invoked, and the returned
action.payload
andaction.meta
properties are checked. - Any property in
action.payload
will be used to substitute URLplaceholders
. So/todos/:id
would replace:id
with the value ofpayload.id
. - By default, the entire
action.payload
is used asdata
for POST/PUT/PATCH requests. However if there is anaction.payload.data
property, then that will be used as data. - All properties of
action.meta
will be added to the generated request config. Note that any property inaction.meta
will override corresponding properties inaction.payload
. If you provide ameta
property it's probably best to just include URL placeholders inpayload
and put all request params inmeta
.
Example. Providing a URL placeholder and PUT data using an action creator:
import { createAction } from 'redux-actions';
export const updateTodo = createAction('UPDATE_TODO', (id, todo) => ({id, data: todo}))
// Your API Config
{
updateTodo: {
url: '/todos/:id',
actions: [updateTodo, updateTodoComplete]
}
}
Then invoking api.updateTodo('1234', todo)
will result in the request:
PUT /todos/1234 data=todo
You can also include a metaCreator
to specify any request config values:
export const updateTodo = createAction(
'UPDATE_TODO',
(id, todo) => ({id}), // for URL placeholder
(id, todo) => ({headers: ..., data: todo, timeout: 1000, ...})
);
Using Action Type Strings
If requestAction
is a string
, then you can pass api.requestAction
two objects, one representing the payload
(with URL placeholders) and one representing meta
properties, with request config properties.
Using the same example:
// API Config
{
updateTodo: {
url: '/todos/:id',
actions: ['UPDATE_TODO', 'UPDATE_TODO_COMPLETE']
}
}
Then you need to invoke api.updateTodos
with two objects, one for URL placeholders and one with request parameters:
function updateTodo(id, todo) {
api.updateTodo({id}, {data: todo});
}
And the resulting PUT
request would be the same as above.
Don't forget to include an empty object or null
as the first paramter if you don't have any URL placeholders:
// API Config
{
createTodo: {
url: '/todos',
actions: ['SAVE_TODO', 'SAVE_TODO_COMPLETE'],
method: 'post'
}
}
function createTodo(todo) {
api.createTodo(null, {data: todo});
}
Getting Data and Request Parameters from Redux
As noted, invoking api.requestAction(...args)
will invoke your requestAction(...args)
before dispatching it. However, if the only or last argument is a function
, it will be invoked with the current Redux state
, and all properties in the returned object will added to the request config. The function
will be removed from the argument list before invoking your requestAction
.
For example, getTodos
takes no arguments, but you can invoke api.getTodos
like this to include query param filters from Redux:
import {api} from 'redux-rest-actions';
import selectFilters from './selectors';
function onFetchTodos() {
api.getTodos(state => ({
params: selectFilters(state)
});
}
If using action type strings
, the same applies to api.requestAction
where you specify a function
as the third parameter:
const placeholders = {id: '123'};
const requestParams = {headers: ..., data: ...};
api.doRequest(placeholders, requestParams, (state) => state.filters);
In this case state.filters
will be added to requestParams
.
Making multiple REST requests with a single action
In the API config, you can specify an array of URLs with the urls
property. They will be sent in parallel using Promise.all
, and your successAction
will be invoked with two arrays, the first array containing response.data
from each URL, and the second array containing response
(the full response object) corresponding to each URL.
NOTE: The response.config
object has all non-serializable properties (functions and promises) removed.
As an example, if your API config has:
getMultipleThings: {
urls: ['/api/things1', '/api/things2'],
actions: [
getMultipleThings,
getMultipleThingsSuccess,
getMultipleThingsError
]
}
Then getMultipleThingsSuccess
will be invoked with:
api.mockAdapter.onGet('/api/things1').reply(200, 'Thing One');
api.mockAdapter.onGet('/api/things2').reply(200, 'Thing Two');
// getMultipleThings will be called with:
getMultipleThings(['Things One, 'Things Two'], [response1, response2]);
If you used string
actions, then action.payload
will be the first array, and action.meta
will be the second array.
If you want your action creator function invoked with separate arguments for the results, you can specify the spread
option:
const getMultipleThingsSuccess = createAction('MULTIPLE_THINGS_SUCCESS', (one, two) => ({one, two}));
// in api config
{
getMultipleThings: {
urls: ['api/things1', 'api/things2'],
spread: true,
actions[getMultipleThings, getMultipleThingsSuccess]
}
}
Then one
will be "Thing One"
and two
will be "Thing Two"
. Your successAction
will still be passed the array of all response objects as the last argument.
Chaining Requests
If one of your actions requires the results of a previous request, you can chain them using a then
target in the api config. In this example getUserPosts
requires getUser
to have been called. When getUser
completes, getUserSuccess
will be dispatched to update state with the user, and then getUserPosts
will be dispatched. The getUserPosts
action creator will be invoked with the getUser
response data, in addition to whatever other arguments you normally invoke it with. In this example, it checks if both cases, and returns the userId
accordingly:
// actions
const getUser = createAction('GET_USER', (id) => ({id}));
const getUserSuccess = createAction('GET_USER_SUCCESS');
const getUserPosts = createAction('GET_USER_POSTS', (idOrPayload) => {
if (typeof obj === 'string') return {userId: idOrPayload};
else return {userId: idOrPayload.id};
});
const getUserPostsSuccess = createAction('GET_USER_POSTS_SUCCESS');
{
getUser: {
url: '/users/:id'
actions: [getUser, getUserSuccess],
then: 'getUserPosts'
},
getUserPosts: {
url: '/posts/:userId'
actions: [getUserPosts, getUserPostsSuccess]
}
}
Alternatively, getUserSuccess
can store the user in Redux state, and getUserPosts
can be invoked from api
by passing a state access function to get the userId
as described above:
import {api} from 'redux-rest-actions';
import selectUser from './selectors';
function onFetchUserPosts() {
api.getUserPosts(state => ({
userId: selectUser(state)
});
}
Behavior when invoking overlapping API requests
When api.requestAction
is invoked while a request is already in progress, you can configure options for how this is handled using the overlappingRequests
api config option. The values are as follows:
sendLatest
(default) - Each time the request action is invoked, a new axios config is generated, but the latest request is not sent until the pending request completes. When the request completes, thesuccessAction
is immediately dispatched, and a newrequestAction
is sent if the latest request config is different from that of the last request (e.g. the query params have changed).debounce
- Similar tosendLatest
in that a new request config is generated for each call toapi.requestAction
, but the actual sending of the request is debounced. IfoverlappingRequests
is set todebounce
, you can configure thedebounceWait
time, anddebounceLeading
and/ordebounceTrailing
options see lodach docs. The defaultdebounceWait
is500
ms, withdebounceLeading
anddebounceTrailing
set totrue
.ignore
- Ignore all overlapping requests. You can use this for things like form submissions, however it's a better UI experience to also enforce this by disable buttons and input elements while the current request is pending
The last two options are probably not as useful, but for completeness:
sendAll
- All requests are sent.cancelPending
- If a request is sent while one is pending, the inprogress request is canceled, and the latest one is sent.
The sendLatest
option sends requests as fast as the server can process them, but only allowing one pending request at a time. An example would be an autocomplete or search function, where the query params change on each request. The default handling of api.search(filter)
with the user typing "ABCD" into the search input would be:
api.search(filters='A')
dispatch(searchAction('A'))
request => GET /api/search?q='A'
api.search(filters='AB')
X (update request config, don't send) => GET /api/search?q='AB'
api.search(filters='ABC')
X (update request config, don't send) => GET /api/search?q='ABC'
api.search(filters='ABCD')
X (update request config, don't send) => GET /api/search?q='ABCD'
<-- response arrives for 'A'
dispatch(searchSuccess(resp.data))
(Since the URL params have changed the request config):
dispatch(searchAction('ABCD'))
request => GET /api/search?q='ABCD'
<-- response arrives for 'ABCD'
dispatch(searchSuccess(resp.data))
If you want to limit the rate at which requests can be sent, use the debounce
option.
If you want to use the same method for all requests, You can specify a third argument to configureApi
which is an object containing any of the overlappingRequests
or debounce
properties above. They will be applied to any request config that does not specifically apply them.
API
configureApiMiddleware
configureApiMiddleware(config = {}, options = {})
The first argument to configureApiMiddleware
is used to configure the Axios instance used by the middleware. All configuration values can be overriden per-request as described above.
Valid options
are:
mockAdapter
- Instance ofaxios-mock-adapter
, see next section.mockDelay
- Mock delay in milliseconds.enableTracing
-true
to enable console log trace of middleware actions.
Using a mock adapter in development
For development, you can set an option to wrap the axios instance with axios-mock-adapter
as shown below. If you use this option then the mock adapter will be available as api.mockAdapter
. It also accepts a mockDelay
value in milliseconds (default 0):
import MockAdapter from 'axios-mock-adapter';
const options = {};
if (process.env.REACT_APP_USE_MOCKS) {
options.mockAdapter = MockAdapter;
options.mockDelay = 2000;
}
const apiMiddleware = configureApiMiddleware({}, opts);
// making requests
if (process.env.REACT_APP_USE_MOCKS) {
api.mockAdapter.onGet('/api/todos').reply(200, ['do stuff']);
}
For testing you can also override the axios instance using jest
or any other mock function. NOTE: If you do this, you must also add the isCancel
and CancelToken
functions on the mock:
import { configureApiMiddleware } from 'redux-rest-actions';
let mockAxios;
beforeEach(() => {
mockApi = jest.fn();
mockApi.isCancel = jest.fn(); // used internally
mockApi.CancelToken = jest.fn(); // used internally
configureApiMiddleware({}, {axios: mockAxios});
});
test('getTodosSuccess', () => {
mockApi.mockImplementation(() => Promise.resolve({data: ['do stuff']}));
...
});
configureApi
configureApi(store, apiConfig, overlappingDefaults = {overlappingRequests: 'sendLatest'})
The first argument is the redux store
. The store is used to dispatch actions invoked on api
. It is also used to provide a way for your actions to get data and URL params from redux state as described above.
The second argument defines API configuration.
The third argument provides default values for handling overlapping requests that get applied to API requests that do not specify the overlappingRequest
or debounce
options.
apiConfig properties
Each named property defines a request action with the following properties:
url
(string) orurls
(array of string) -required
. The REST URL to invoke, which may contain placeholders, like/api/todos/:id
which will be substitued as desribed below. You can usebaseURL
of the axios config to prepend a common base. If the URL is fully qualified,baseURL
will not be used. If you specify an array of URLs they will be fetched in parallel withPromise.all
, and the data delivered to your action will be an array of the response data in the same order.actions
(array of strings or objects) -required
. The actions are ordered asrequestAction
,successAction
,errorAction
andcanceledAction
, with the request and success actions being required. If these are action creator functions, they will be invoked before dispatch. Invoking therequestAction
allows you to pass data and url params to the API as described below. If the actions are strins (action types), the dispatched action will be{type: action, payload: {response or error}}
.method
(string) -optional
if the method can be detected from the request property name as described below.overlappingRequests
(string) -optional
, default issendLatest
. May also bedebounce
,ignore
,sendAll
, or `cancelPending.debounceWait
(number) - the debounce wait time, in milliseconds. Default is500
.debounceLeading
(boolean) - Send debounced requests on leading edge. Default istrue
.debounceTrailing
(boolean) - Send debounced requests on trailing edge. Default istrue
.configOption
-optional
. Any per-request configuration defined here.
Default method names
Default method names, based on the name of the request action prefix are determined as follows:
const getNamePrefixes = ['fetch', 'get', 'retrieve']; // get
const postNamePrefixes = ['add', 'create']; // post
const putNamePrefixes = ['update', 'save', 'put']; // put
const deleteNamePrefixes = ['remove', 'delete']; // delete
api.requestAction
The return value from api.requestAction
is a promise in all cases except when overlappingRequests
is set to debounce
(in which case it's undefined
, because the function is always invoked later). You can use this in your UI components to take action when the promise completes successfully. For example, if your action submits a form using overlappingRequests
set to ignore
, you can navigate to the home page when the submit is complete:
function onSubmitForm(data) {
api.onSubmit(data).then(() => history.push('/home'));
}
NOTE: The promise returned by api.requestAction
is only for the action itself, and does not include any chained action using a then
request in the api config.