Skip to content

mikechabot/redux-entity

Repository files navigation

logo

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.

react

While loadOrders is pending, isFetching is set to true:

fetching

If loadOrders succeeds, the results are stamped on the store at entities.orders.data, and lastUpdated is set:

succeed

If loadOrders fails, the results are stamped on the store at entities.orders.error, and lastUpdated is set:

fail

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.

state


The guide below assumes you've already injected the Redux store into your React application.

1. Configure the root reducer

Follow along with Integrate into Redux to integrate the reducer into your existing Redux store.

2. Create a custom thunk

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);

3. Create a React component

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',
}

Example Configurations

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

Example usage

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>
  );
}

About

A predictable approach to maintaining domain entities in Redux

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •