redux-entities-module
v2.2.0
Published
A boilerplate-free entity module for Redux
Downloads
21
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.
- You'll need something to handle async fetching, like redux-thunk or redux-saga.
- If you want to show loading states, you'll need various action creators.
- You'll have to write a reducer to handle each of those actions.
- You'll need to write selectors to pull those entities out of redux.
- Finally, you'll duplicate all of the above for each new endpoint added.
And even then, you still have problems like:
- When to dispatch the action to fetch an entity vs using one already stored.
- 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:
- Curried API functions that call the given endpoint.
- Curried selector functions that will select the entities.
- Curried saga that actually fires the request and the various actions for pending/success/error states.
- A curried reducer that will handle storing the entities.
useEntity
anduseEntityList
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.
- 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 });
- 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,
}
]
});
- Create a module from those entities:
const module = createEntitiesModule({
lesson: lessonEntity,
course: courseEntity,
});
- 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);
}
- 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
- selectors.*.retrieve
- selectors.*.only
- selectors.*.find
- selectors.*.filter
- selectors.*.list
- selectors.*.getCreate
Actions
Creating Entities
createEntityBuilder
Creates an entity builder with the provided Fetcher.
Arguments
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:
- EntityInterface - an interface for the entity's data.
- CreateEntityInterface - an interface for the payload used when creating a new entity.
Use like: createEntity<EntityInterface, CreateEntityInterface>(...)
Arguments
name
(string - required) - The name of the entity.url
(string - required) - The URL of the API endpoint.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 becourses
. If not provided, the plural form ofname
will be used.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.
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())
include
(string[]) - a list of strings to be passed to the API as inclusions. Each string will have.*
appended to it, soinclude: ['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 genericquery
argument can be added if necessary.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
id
(string - required) - The ID of the entity to fetchrefresh
(boolean) - Whether to always fetch the entity even if it's already in the store. Defaults tofalse
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
query
(object - optional) - An optional query object to stringify when making the request. Passnull
to conditionally call the hook.refresh
(boolean - optional) - Whether to always fetch the list even if it's already in the store. Defaults tofalse
.
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
- state (redux state - required)
- ID (string - required) - the ID of the entity to select.
- 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
- state (redux state)
- 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
state (redux state - required)
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;
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
state (redux state - required)
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
- state (redux state - required)
- query (string - required) - the query used to fetch the original list.
- 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
- 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
- state (redux state - required)
- 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
- state (redux state - required)
- 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
- id (string - required) - The ID of the entity. E.g.
123
will call/myEntityUrl/123
. - 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
- payload (CreateEntityInterface - required) - The payload to use the payload for the post request.
- options ({fetchListOnSuccess: object | boolean} - optional)
fetchListOnSuccess
- Whether to fetch the entity's list after successfully creating a new object. Iftrue
, 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
- id (string - required) - The ID of the entity. E.g.
123
will call/myEntityUrl/123
.