redux-entities-module

A boilerplate-free entity module for Redux

Usage no npm install needed!

<script type="module">
  import reduxEntitiesModule from 'https://cdn.skypack.dev/redux-entities-module';
</script>

README

Redux Entities Module

When working with redux and a REST API, you'll find yourself writing a lot of boilerplate code to handle fetching data.

  1. You'll need something to handle async fetching, like redux-thunk or redux-saga.
  2. If you want to show loading states, you'll need various action creators.
  3. You'll have to write a reducer to handle each of those actions.
  4. You'll need to write selectors to pull those entities out of redux.
  5. Finally, you'll duplicate all of the above for each new endpoint added.

And even then, you still have problems like:

  1. When to dispatch the action to fetch an entity vs using one already stored.
  2. Handling duplicated entities if the API nest entities within other entities.

This library aims to solve those problems.

Table of Contents

Installation

yarn add redux-entities-module

Also, you need to install redux, redux-saga, react-redux, react, & react-dom

Usage

The entity system is powered by a creating entities. An entity takes some configuration and provides:

  1. Curried API functions that call the given endpoint.
  2. Curried selector functions that will select the entities.
  3. Curried saga that actually fires the request and the various actions for pending/success/error states.
  4. A curried reducer that will handle storing the entities.
  5. useEntity and useEntityList hooks that can be used to easily fetch the data for use in a component.

These entities can then be hooked up to your root reducer and saga.

  1. Start by defining a entity builder:
import { Fetch, createEntityBuilder, createEntitiesModule } from 'redux-entities-module'

const fetch = new Fetch({ baseUrl: 'https://api.com' });
const builder = createEntityBuilder({ fetch });
  1. Then create entities for your API resources:
interface Lesson {
    id: string;
    name: string;
}

const lessonEntity = builder.createEntity<Lesson>({
    name: 'lesson',
    url: '/lessons',
});

interface Course {
    id: string;
    name: string;
    lessons: string[];
}

/*
 * Defines a course entity for an API that returns data like:
 * {
 *      "id": "123",
 *      "name": "My course",
 *      "lessons": [
 *          {
 *              "id": "1",
 *              "name": "Lesson 1",
 *          },
 *          {
 *              "id": "2",
 *              "name": "Lesson 2",
 *          },
 *      ]
 * }
 *
 * When fetching /courses, they'll automatically be normalized and their
 * lessons stored separately.
 */
const courseEntity = builder.createEntity<Course>({
    name: 'course',
    url: '/courses',
    nestedEntities: [
        {
            field: 'lessons',
            module: lessonEntity,
            many: true,
        }
    ]
});
  1. Create a module from those entities:
const module = createEntitiesModule({
    lesson: lessonEntity,
    course: courseEntity,
});
  1. Connect the module to your root saga and reducer:
import { combineReducers } from 'redux';

import module from './entities';

const rootReducer = combineReducers({
    entities: module.reducer,
});

function* rootSaga() {
    yield fork(module.saga);
}
  1. Use the module:
import module from './entities';
import Loading from './Loading';

function Lesson({lessonId}: {lessonId: string}) {
     const lessonState = module.useEntity.lesson(lessonId);

     if (!lessonState.ready) {
         return <Loading />;
     }

     return <h2>{lessonState.entity.name}</h2>;
}


function Course({courseId}: {courseId: string}) {
     const courseState = module.useEntity.course(courseId);

     if (!courseState.ready) {
         return <Loading />;
     }

     return <h2>{courseState.entity.name}</h2>;
}

function Courses() {
     const courseListState = module.useEntityList.course();

     if (!courseListState.ready) {
         return <Loading />;
     }

     return (
         <div>
             {courseList.map(courseId =>
                 <Course key={courseId} courseId={courseId} />
             )}
         </div>
     );
}

API

Builder

Hooks

Selectors

Actions

Creating Entities

createEntityBuilder

Creates an entity builder with the provided Fetcher.

Arguments

  1. fetch (Fetch - required) - An instance of Fetch.

Returns [builder] - An entity builder that provides a single function createEntity.


builder.createEntity

Creates an entity with curried functions for actions, selectors, reducer, saga, and hooks.

Typescript generics:

  1. EntityInterface - an interface for the entity's data.
  2. CreateEntityInterface - an interface for the payload used when creating a new entity.

Use like: createEntity<EntityInterface, CreateEntityInterface>(...)

Arguments

  1. name (string - required) - The name of the entity.

  2. url (string - required) - The URL of the API endpoint.

  3. itemsKey (string) - An envelope string used to read into the list response from the API. For example, if your API returns { courses: [...] }, your itemsKey will be courses. If not provided, the plural form of name will be used.

  4. nestedEntities (array) - An array of nested entity definitions. Each definition should have the shape:

    {
        "field": [field in the response],
        "module": [relatedModule],
        "many": boolean,
    }
    

    The field will be used to read into the the response for the parent entity. For example:

    {
        "id": "123",
        "name": "My course",
        "lessons": [{...}, {...}, ...],
    }
    

    along with the nested entity definition:

    {
        "field": "lessons",
        "module": lessonEntity,
        "many": true,
    }
    

    will automatically normalize the lessons in the course and store the lessons in their own entity.

  5. extraMethods (object) - An object that defines any extra methods available for this API resource. An extra method should look like:

    {
        [methodName: string]: {
            type: 'detail' | 'list',
            httpMethod: 'post' | 'patch' | 'delete',
            url: string,
        }
    }
    

    For example:

    {
        publish: {
            type: 'detail',
            httpMethod: 'post',
            url: 'publish',
        }
    }
    

    will allow you to call /courses/123/publish by dispatching the publish action:

    dispatch(module.actions.course.publish())
    
  6. include (string[]) - a list of strings to be passed to the API as inclusions. Each string will have .* appended to it, so include: ['foo', 'bar'] will be formatted as ?include[]=foo.*&include[]=bar.*. This is very specific to the author's API so this is unlikely to be useful broadly. A generic query argument can be added if necessary.

  7. isSingleton (boolean) - Set to true if the entity is a singleton resource, for example, its usual for a user identity endpoint like /me to be a singleton. A singleton will only ever store a single entity object for this entity and won't required providing IDs when using the entity's actions.


Hooks

Use these hooks to fetch and select entities from the store. This should be the primary way you use entities in your components. The hooks take care of fetching, selecting, and optionally refreshing the entities.


useEntity

When you're writing a component and want to fetch and use an entity, use useEntity.[ENTITY_NAME]. By default this will only fetch the entity if it's not already present in the store.

Arguments

  1. id (string - required) - The ID of the entity to fetch
  2. refresh (boolean) - Whether to always fetch the entity even if it's already in the store. Defaults to false

Returns

entityState: The entity itself, wrapper as a state object. A state object takes the shape:

{
    status: 'pending' | 'success' | 'error' | 'reloading',
    entity: Entity,
    ready: boolean,
    error: null | Error,
}

It's advisable to check ready before trying to render.

Sometimes you'll want to fetch an entity that might not exist in the API. Perhaps an entity has an object foreign key. Because react hooks can't be called conditionally, you can instead pass null as the id which will essentially turn the hook into a noop. When null is passed, the entityState returned will be null.


useEntityList.*

When you're writing a component and want to fetch a list of entities, e.g. /lessons, use useEntityList.[ENTITY_NAME]

Arguments

  1. query (object - optional) - An optional query object to stringify when making the request. Pass null to conditionally call the hook.
  2. refresh (boolean - optional) - Whether to always fetch the list even if it's already in the store. Defaults to false.

Returns

listEntityState: The entity list itself, wrapper as a state object. A list state object takes the shape:

{
    status: 'pending' | 'success' | 'error' | 'reloading',
    list: [...entityIds],
    ready: boolean,
    error: null | Error,
    slug: string,
}

It's advisable to check ready before trying to render.


Selectors

Most of the time the entity hooks should give you what you need, but the module provides selectors if you need them.


selectors.*.retrieve

Selects an entity from the store based on ID.

Arguments

  1. state (redux state - required)
  2. ID (string - required) - the ID of the entity to select.
  3. d (any - optional) - a default to use if an entity isn't found. By default the selector will throw if an entity is found. Provide a default to avoid this.

Returns

entityState | [d] - returns the entityState or the default, if provided.


selectors.*.only

If an entity is a singleton, like /me, then often you don't know the ID of the entity you're selecting. That's where the only selector comes in. It doesn't require an ID and will throw if it finds multiple entities.

Arguments

  1. state (redux state)
  2. d (any - optional) - a default to use if an entity isn't found. By default the selector will throw if an entity is not found. Provide a default to avoid this.

Returns

entityState | [d] - returns the entityState or the default, if provided.

selectors.*.find

Selects an entity from the store based on a filter query.

Arguments

  1. state (redux state - required)

  2. query (object | function - required) - A query to use to attempt to find the entity. Either an object with the shape:

    {
        [propertyName: string]: string | number
    }
    

    or a function:

    (
      e: EntityState<EntityInterface>,
      index: number,
      array: EntityState<EntityInterface>[],
    ): boolean;
    
  3. d (any - optional) - a default to use if an entity isn't found. By default the selector will throw if an entity with the given query is not found. Provide a default to avoid this.

Returns

entityState | [d] - returns the entityState or the default, if provided.


selectors.*.filter

Selects one or more entities from the store based on a filter query.

NOTE: This selector provides mutated state. It's cached automatically and will update if the query or any of the matched entities changes, but checking the cache could be an expensive operation. Try to avoid using this if possible.

Arguments

  1. state (redux state - required)

  2. query (object | function - required) - A query to use to filter the entities. Either an object with the shape:

    {
        [propertyName: string]: string | number
    }
    

    or a function:

    (
      e: EntityState<EntityInterface>,
      index: number,
      array: EntityState<EntityInterface>[],
    ): boolean;
    

Returns

entityState[] - returns a list of matching entities


selectors.*.list

Selects an entity list from the store.

Arguments

  1. state (redux state - required)
  2. query (string - required) - the query used to fetch the original list.
  3. d (any - optional) - a default to use if an entity isn't found. By default the selector will throw if an entity list with the given query is not found. Provide a default to avoid this.

Returns

entityState | [d] - returns the entityState or the default, if provided.


selectors.*.getCreate

Gets the status of any create operations

Arguments

  1. state (redux state - required)

Returns

object - the status of any create operations with the shape:

{
    status: "pending" | "success" | "error",
    error: Error | null,
}

Action creators

Each entity comes with its own action creators. Most of the time the actions you'll use will be update, create, and delete as well as any custom methods your API has. The retrieve and list actions are most often used via the entity hooks.

Note: If isSingleton is true for the entity, none of the actions below require the id parameter.

actions.*.retrieve

Retrieves an entity from the API.

Arguments

  1. state (redux state - required)
  2. id (string - required) - The ID of the entity. E.g. 123 will call /myEntityUrl/123.

actions.*.list

Retrieves a list of entities from the API.

Arguments

  1. state (redux state - required)
  2. query (string - optional) - If provided, will be stringified and provided as query params. E.g {version: 2} will call /myEntityList?version=2. The same query should be used when selecting the entity list.

actions.*.update

Updates an entity using patch.

Arguments

  1. id (string - required) - The ID of the entity. E.g. 123 will call /myEntityUrl/123.
  2. payload (Partial - required) - A partial version of the entity to use as the payload for the PATCH request.

actions.*.create

Creates a new entity.

Arguments

  1. payload (CreateEntityInterface - required) - The payload to use the payload for the post request.
  2. options ({fetchListOnSuccess: object | boolean} - optional)
    • fetchListOnSuccess - Whether to fetch the entity's list after successfully creating a new object. If true, the default list will be fetched (with no query). If an object is provided, it'll be used as the query to use when fetching the list.

actions.*.delete

Deletes an entity.

Arguments

  1. id (string - required) - The ID of the entity. E.g. 123 will call /myEntityUrl/123.