At its core, redux-entity
is just a reducer that utilizes a specialized thunk, which is designed to handle asynchronous actions in the form of a Promise.
Most web applications need to handle a variety of domain entities such as orders, products, users, etc. This library was designed to manage these objects within Redux in a predictable and scalable way.
Check out the demo repository at https://github.com/mikechabot/react-boilerplate
$ npm install redux-entity
$ yarn add redux-entity
The API is very simplistic; a thunk called GetEntity
is exposed, which does all the heavy lifting.
Every entity you fetch is automatically associated with the following properties to ensure predictability. No need to track these yourself.
interface EntityState {
/** Data returned from the resolved promise */
data?: any;
/** Error returned from the rejected promise */
error?: Error;
/** Whether the entity promise is pending */
isFetching: boolean;
/** Timestamp of the promise's last resolution or rejection */
lastUpdated?: Date;
}
To get started, import the reducer
from redux-entity
, and combine with your existing reducers.
By default, we're carving out a space in the Redux tree with the key of
entities
, but you can rename it to whatever you'd like.
// root-reducer.ts
import { reducer as entities } from 'redux-entity';
import { combineReducers } from 'redux';
export default combineReducers({
...<existing reducers>,
entities
});
Now we're ready to use GetEntity
.
When using GetEntity
, you only need to provide two elements: a key to uniquely identify the entity, and a promise to fetch the data.
import { GetEntity } from 'redux-entity';
import OrderService from './services/order-service';
const key = 'orders';
const promise = OrderService.getOrders();
export const loadOrders = () => GetEntity(key, promise);
Let's take a look at what the Redux store looks like when loadOrders
is invoked.
In the context of React, let's say we have an
<Orders />
component; when the component mounts, we'll want to fetch our data. See Detailed Usage for the full React component.
While loadOrders
is pending, isFetching
is set to true:
If loadOrders
succeeds, the results are stamped on the store at entities.orders.data
, and lastUpdated
is set:
If loadOrders
fails, the results are stamped on the store at entities.orders.error
, and lastUpdated
is set:
If we need to load more entities, we just create additional thunks with GetEntity
, and invoke them as described above.
Every entity we fetch will be stamped on the
entities
tree.
The guide below assumes you've already injected the Redux store into your React application.
Follow along with Integrate into Redux to integrate the reducer into your existing Redux store.
Create a thunk using GetEntity
. You only need to provide a key that uniquely identifies the entity, and a data promise.
You can optionally pass a configuration to
GetEntity
. See Configuration:
import { GetEntity } from 'redux-entity';
import OrderService from './services/order-service';
const entityKey = 'orders';
const promise = OrderService.getOrders();
export const loadOrders = () => GetEntity(key, promise);
Here's a full React component that utilizes our loadOrders
example. At this point, loadOrders
is no different than any other Redux thunk.
Check out the CodeSandbox
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { loadOrders } from './utils';
import Buttons from './Buttons';
import State from './State';
export default function Orders() {
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadOrders());
}, [dispatch]);
const { orders } = useSelector((state) => state.entities);
let body, isFetching;
if (orders) {
isFetching = orders.isFetching;
const { data, error } = orders;
if (isFetching) {
body = 'Fetching Orders...';
} else if (error) {
body = error.message;
} else if (data) {
body = `Found ${orders.data.length} Orders!`;
}
}
return (
<div>
{body}
<br />
<Buttons disabled={isFetching} />
<State />
</div>
);
}
Optionally pass a configuration with any of the following properties:
Argument | Default | Description |
---|---|---|
silent |
false |
If true , don't toggle isFetching when the thunk is invoked |
append |
false |
If true , append the results of each invocation to the existing data property instead of overwriting it |
processors |
undefined |
Hook into the GetEntity lifecycle. Each processor has access to Redux's dispatch and getState along with either the data or error object of the entity. See Processors |
The options configuration must adhere to the following interface:
interface ReduxEntityOptions {
[OptionKey.Silent]?: boolean;
[OptionKey.Append]?: boolean;
[OptionKey.Processors]?: Processors;
}
enum OptionKey {
Silent = 'silent',
Append = 'append',
Processors = 'processors',
}
Simple configuration:
const key = 'orders';
const promise = OrderService.getOrders();
const options = { silent: true, append: true };
export const loadOrders = () => GetEntity(key, promise, options);
Dynamically pass a configuration:
const key = 'orders';
const promise = OrderService.getOrders();
export const loadOrders = (options) => GetEntity(key, promise, options);
Processors are optional and in most cases won't be needed, however you can take additional action when an entity's promise either resolves or rejects by hooking into the processors below.
Processor | When is this executed? |
---|---|
beforeSuccess |
After promise resolution, but before data is dispatched to the store. Must return any |
afterSuccess |
After promise resolution, and after the store has been updated |
beforeFailure |
After promise rejection, but before the error is dispatched to the store. Must return error |
afterFailure |
After promise rejection, and after the store has been updated |
The processor object must adhere to the following interface:
type Processors = {
[key in ProcessorType]?: (
data: any,
dispatch: ThunkDispatch<ReduxEntityState, unknown, AnyAction>,
getState: GetState
) => any | void;
};
enum ProcessorType {
BeforeSuccess = 'beforeSuccess',
AfterSuccess = 'afterSuccess',
BeforeFailure = 'beforeFailure',
AfterFailure = 'afterFailure',
}
Configuration with processors:
const key = 'orders';
const promise = OrderService.getOrders();
const options = {
silent: true,
processors: {
beforeSuccess: (data, dispatch, getState) => {
// Do synchronous stuff
// *Must* return data to be dispatched to the store
return Object.keys(data);
},
beforeFailure: (error, dispatch, getState) => {
// Do synchronous stuff
// *Must* return an error to the dispatched to the store
return new Error('Intercepted error!');
},
},
};
export const loadOrders = () => GetEntity(key, promise, options);
The following actions can be use to reset or delete your entity.
Check out the Demos to see these in action.
Action creator | Description |
---|---|
ResetEntity |
Reset the entity to the original EntityState , and set lastUpdated |
DeleteEntity |
Delete the entity from state |
Check out the CodeSandbox
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Buttons from './Buttons';
import State from './State';
import { loadOrders } from './utils';
export default function App() {
const { orders } = useSelector((state) => state.entities);
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadOrders());
}, [dispatch]);
let body, isFetching;
if (orders) {
isFetching = orders.isFetching;
const { data, error } = orders;
if (isFetching) {
body = <em>Fetching Orders...</em>;
} else if (error) {
body = <span className="error">{error.message}</span>;
} else if (data) {
body = `Found ${orders.data.length} Orders!`;
} else {
body = 'No Data!';
}
} else {
body = 'No Entity!';
}
return (
<div className="app">
<h3>Playground</h3>
<div className="body">{body}</div>
<Buttons disabled={isFetching} />
<State />
</div>
);
}