A zero-boilerplate higher-order reducer for managing normalized relational data
npm install normalized-reducerš easy to get started and use without writing any action/reducer logic
⨠handles basic CRUD, plus complex updates like entity associations and cascading changes from deletes
š¦ dependency-free and framework-agnostic; use with or without Redux
š integrates with Normalizr and Redux-Toolkit
Table of Contents:
- The Problem
- The Solution
- Install
- Quick Start
- Demo
- Comparison to Alternatives
- Top-level API
- Parameter: schema
- Parameter: namespaced
- Generic Parameter:
- Return Value
- Action-creators API
- create
- delete
- update
- attach
- detach
- move
- moveAttached
- sort
- sortAttached
- batch
- setState
- Selectors API
- getIds
- getEntities
- getEntity
- Normalizr Integration
- LICENSE
yarn add normalized-reducer
1. Define a schema that describes your data's relationships.
``javascript`
const mySchema = {
list: {
'itemIds': { type: 'item', cardinality: 'many', reciprocal: 'listId' }
},
item: {
'listId': { type: 'list', cardinality: 'one', reciprocal: 'itemIds' },
'tagIds': { type: 'tag', cardinality: 'many', reciprocal: 'itemIds'}
},
tag: {
'itemIds': { type: 'item', cardinality: 'many', reciprocal: 'tagIds' }
}
}
schema
More info at: Top-level API > Parameter:
2. Pass in the schema, and get back a reducer, action-creators, action-types, selectors, and empty state.
`javascript`
import makeNormalizedSlice from 'normalized-reducer'
const {
reducer,
actionCreators,
actionTypes,
selectors,
emptyState,
} = makeNormalizedSlice(mySchema)
More info at: Top-level API > Return Value
3. Use the reducer and actions to update the state. The following example assumes the use of dispatch from either React or React-Redux.
With React:
`javascript`
const [state, dispatch] = useReducer(reducer, emptyState);
`
With React-Redux:
javascript`
const dispatch = useDispatch();
Usage:
`javascript
// add entities
dispatch(actionCreators.create('item', 'i1')) // add an 'item' entity with an id of 'i1'
dispatch(actionCreators.create('list', 'l1', { title: 'first list' }), 3) // add a 'list' with id 'l1', with data, at index 3
// delete entities
dispatch(actionCreators.delete('list', 'l1')) // delete a 'list' entity whose id is 'l1'
// update entities
dispatch(actionCreators.update('item', 'i1', { value: 'do a barrel roll!' })) // update 'item' whose id is 'l1', patch (partial update)
dispatch(actionCreators.update('item', 'i1', { value: 'the sky is falling!' }, { method: 'put' })) // update, put (replacement update)
// change an entity's ordinal value
dispatch(actionCreators.move('item', 0, 1)) // move the 'item' entity at index 0 to index 1
// attach entities
dispatch(actionCreators.attach('list', 'l1', 'item', 'i1')) // attach list l1 to item i1
// detach entities
dispatch(actionCreators.detach('list', 'l1', 'item', 'i1')) // detach list l1 from item i1
// change an entity's ordinal value with respect to another entity
dispatch(actionCreators.moveAttached('list', 'l1', 'itemIds', 1 , 3)) // in item l1's .itemIds, move the itemId at index 1 to index 3
// batch: all changes will occur in a single action
dispatch(actionCreators.batch(
actionCreators.create('list', 'l10'),
actionCreators.create('item', 'i20'),
actionCreators.attach('item', 'i20', 'listId', 'l10'),
))
// sort entities
dispatch(actionCreators.sort('item', (a, b) => (a.title > b.title ? 1 : -1))) // sort items by title
// sort entities with respect to an attached entity
dispatch(actionCreators.sortAttached('list', 'l1', 'itemIds', (a, b) => (a.value > b.value ? 1 : -1))) // in item l1's .itemIds, sort by value
`
More info at: Action-creators API
4. Use the selectors to read state.
`javascript`
const itemIds = selectors.getIds(state, { type: 'item' }) // ['i1', 'i2']
const items = selectors.getEntities(state, { type: 'item' }) // { 'i1': { ... }, 'i2': { ... } }
const item = selectors.getEntity(state, { type: 'item', id: 'i2' }) // { value: 'the sky is falling!', listId: 'l1' }
More info at: Selectors API
5. The empty state shape looks like:
`json`
{
"entities": {
"list": {},
"item": {},
"tag": {}
},
"ids": {
"list": [],
"item": [],
"tag": []
}
}
`
And a populated state could look like:
json`
{
"entities": {
"list": {
"l1": { "itemIds": ["i1", "i2"] }
},
"item": {
"i1": { "listId": "l1" },
"i2": { "listId": "l1", "tagIds": ["t1"] }
},
"tag": {
"t1": { "itemIds": ["i2"] }
}
},
"ids": {
"list": ["l1"],
"item": ["i1", "i2"],
"tag": ["t1"]
}
}
Demos:
- Create
- Create, indexed
- Update
- Move
- Delete
- Attach/detach, one-to-many
- Attach/detach, many-to-many
- Attach/detach, one-to-one
- Move attached
- Delete + detach
- Sort
- Sort attached
- Batch
- Set state
Example usage:
- Sortable tags list
- Comment tree
- Directory tree (composite tree)
- Normalizr Integration
- Redux Toolkit Integration
Comparison to Redux ORM:
- Normalized Reducer
- does not depend on Redux
- supports ordering of children (attached entities),
- does not require any non-declarative logic
- is lighter and dependency-free
- Redux ORM
- has more advanced selectors features
- is more mature
Comparison to Redux Tookit's entity adapter
- Normalized Reducer
- performs relational state management
- is dependency-free
- Redux Tookit's entity adapter
- supports automatic entity ordering
- is more mature and backed by Redux authorities
and an optional namespaced argument and returns a reducer, action-creators, action-types, selectors, and empty state.
`
makeNormalizedSlice(schema: ModelSchema, namespaced?: Namespaced): {
reducer: Reducer,
actionCreators: ActionCreators,
actionTypes: ActionTypes,
selectors: Selectors,
emptyState: S,
}
`Example:
`javascript
import makeNormalizedSlice from 'normalized-reducer';const {
reducer,
actionCreators,
actionTypes,
selectors,
emptyState,
} = makeNormalizedSlice(mySchema, namespaced);
`$3
The schema is an object literal that defines each entity and its relationships.`typescript
interface Schema {
[entityType: string]: {
[relationKey: string]: {
type: string;
reciprocal: string;
cardinality: 'one'|'many';
}
}
}
`Example:
`javascript
const schema = {
list: {
// Each list has many items, specified by the .itemIds attribute
// On each item, the attribute which points back to its list is .listId
itemIds: {
type: 'item', // points to schema.item
reciprocal: 'listId', // points to schema.item.listId
cardinality: 'many'
}
},
item: {
// Each item has one list, specified by the attribute .listId
// On each list, the attribute which points back to the attached items is .itemIds
listId: {
type: 'list', // points to schema.list
reciprocal: 'itemIds', // points to schema.list.itemIds
cardinality: 'one'
},
},
};
`Note that
type must be an entity type (a top-level key) within the schema, and reciprocal must be a relation key within that entity's definition. $3
This is an optional argument that lets you namespace the action-types, which is useful if you are going to compose the Normalized Reducer slice with other reducer slices in your application.Example:
`javascript
const namespaced = actionType => my-custom-namespace/${actionType};
` If the
namespaced argument is not passed in, it defaults to normalized/.$3
The shape of the state, which must overlap with the following interface:
`typescript
export type State = {
entities: {
[type: string]: {
[id in string|number]: { [k: string]: any }
}
},
ids: {
[type: string]: (string|number)[]
},
};
`Example:
`typescript
interface List {
itemIds: string[]
}interface Item {
listId: string
}
interface State {
entities: {
list: Record,
item: Record
},
ids: {
list: string[],
item: string[]
}
}
const normalizedSlice = makeNormalizedSlice(schema)
`$3
Calling the top-level function will return an object literal containing the things to help you manage state:-
reducer
- actionCreators
- actionTypes
- selectors
- emptyState$3
A function that accepts a state + action, and then returns the next state.
`
reducer(state: S, action: { type: string }): S
` In a React setup, pass the reducer into
useReducer:
`js
function MyComponent() {
const [normalizedState, dispatch] = useReducer(reducer, emptyState)
}
`In a Redux setup, compose the reducer with other reducers, or use it as the root reducer:
`js
const { reducer } = makeNormalizedSlice(schema)// compose it with combineReducers
const reduxReducer = combineReducers({
normalizedData: reducer,
//...
})
// or used it as the root reducer
const store = createStore(reducer)
`
$3
An object literal containing action-creators. See the Action-creators API section.$3
An object literal containing the action-types.`javascript
const {
CREATE,
DELETE,
UPDATE,
MOVE,
ATTACH,
DETACH,
MOVE_ATTACHED,
SORT,
SORT_ATTACHED,
BATCH,
SET_STATE,
} = actionTypes
`namespaced
parameter of the top-level function. Example: normalized/CREATE$3
An object literal containing the selectors. See the Selectors API section.$3
An object containing empty collections of each entity.Example:
`json
{
"entities": {
"list": {},
"item": {},
"tag": {}
},
"ids": {
"list": [],
"item": [],
"tag": []
}
}
`
Action-creators API
An action-creator is a function that takes parameters and returns an object literal describing how the reducer should enact change upon state. $3
Creates a new entity
`
( entityType: string,
id: string|number,
data?: object,
index?: number
): CreateAction
`
Parameters:
- entityType: the entity type
- id: an id that doesn't belong to an existing entity
- data: optional, an object of arbitrary, non-relational data
- index: optional, a number greater than 0Note:
- the
id should be a string or number provided by your code, such as a generated uuid
- if the id already belongs to an existing entity, then the action will be ignored.
- if no data is provided, then the entity will be initialized as an empty object.
- if relational attributes are in the data, then they will be ignored; to add relational data, use the attach action-creator after creating the entity.
- if an index is provided, then the entity will be inserted at that position in the collection, and if no index is provided the entity will be appended at the end of the collection. Example:
`javascript
// create a list with a random uuid as the id, and a title, inserted at index 3
const creationAction = actionCreators.create('list', uuid(), { title: 'shopping list' }, 3)
`Demos:
- Create
- Create, indexed
$3
Deletes an existing entity
`
( entityType: string,
id: string|number,
cascade?: SelectorTreeSchema
): DeleteAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- cascade: optional, an object literal describing a cascading deletionNote:
- any entities that are attached to the deletable entity will be automatically detached from it.
- pass in
cascade to delete entities that are attached to the deletable entityBasic Example:
`javascript
// deletes a list whose id is 'l1', and automatically detaches any entities currently attached to it
const deletionAction = actionCreators.delete('list', 'l1');
`Cascade Example:
`javascript
/*
deletes list whose id is 'l1',
deletes any items attached to 'l1'
deletes any tags attached to those items
detaches any entities attached to the deleted entities
*/
const deletion = actionCreators.delete('list', 'l1', { itemIds: { tagIds: {} } });
`Demos:
- Delete
- Delete + detach
$3
Updates an existing entity
`
( entityType: string,
id: string|number,
data: object,
options?: { method?: 'patch'|'put' }
): UpdateAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- data: an object of any arbitrary, non-relational data
- options.method: optional, whether to partially update or completely replace the entity's non-relational dataNote:
- if an entity with the
id does not exist, then the action will be ignored
- if relational attributes are in the data, then they will be ignored; to update relational data, use the attach and detach action-creators.
- if no method option is provided, then it will default to a patch (partial update)Example:
`javascript
// updates a list whose id is 'l1', partial-update
const updateAction = actionCreators.update('list', 'l1', { title: 'do now!' })// updates a list whose id is 'l1', full replacement
const updateAction = actionCreators.update('list', 'l1', { title: 'do later' }, { method: 'put' })
`Demos:
- Update
$3
Attaches two existing related entities
`
( entityType: string,
id: string|number,
relation: string,
relatedId: string|number,
options?: { index?: number; reciprocalIndex?: number }
): AttachAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- relation: a relation key or relation type
- attachableId: the id of an existing entity to be attached
- options.index: optional, the insertion index within the entity's attached-id's collection
- options.reciprocalIndex: optional, same as options.index, but the opposite directionNote:
- if either entity does not exist, then the action will be ignored
- if the relation does not exist as defined by the schema, then the action will be ignored,
- a has-one attachment can be displaced by a new attachment, and such a case, those displaced entities will automatically be detached
- if indexing is not applicable for a given relationship, i.e. a has-one, then the indexing option will be ignored
Example:
`javascript
/*
attaches item 'i1' to tag 't1'
in item i1's tagIds array, t1 will be inserted at index 2
in tag t1's itemIds array, i1 will be inserted at index 3
*/
const attachmentAction = actionCreators.attach('item', 'i1', 'tagIds', 't1', 2, 3);
`Displacement example:
`javascript
// attach list 'l1' to item 'i1'
const firstAttachment = actionCreators.attach('list', 'l1', 'itemId', 'i1');// attach list 'l20' to item 'i1'
// this will automatically detach item 'i1' from list 'l1'
const secondAttachment = actionCreators.attach('list', 'l20', 'itemId', 'i1');
`Demos:
- Attach/detach, one-to-many
- Attach/detach, many-to-many
- Attach/detach, one-to-one
$3
Detaches two attached entities
`
( entityType: string,
id: string|number,
relation: string,
detachableId: string|number
): DetachAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- relation: a relation key or relation type
- detachableId: the id on an existing entity to be attachedExample:
`javascript
// detach item 'i1' from tag 't1'
const detachmentAction = actionCreators.detach('item', 'i1', 'tagIds', 't1')
`Demos:
- Attach/detach, one-to-many
- Attach/detach, many-to-many
- Attach/detach, one-to-one
$3
Changes an entity's ordinal position
`
( entityType: string,
src: number,
dest: number
): MoveAction
`
Parameters:
- entityType: the entity type
- src: the source/starting index of the entity to reposition
- dest: the destination/ending index; where to move the entity toNote:
- if either
src or dest is less than 0, then the action will be ignored
- if src greater than the highest index, then the last entity will be moved
- if dest greater than the highest index, then, the entity will be move to last positionExample:
`javascript
// move the item at index 2 to index 5
const moveAction = actionCreators.move('item', 2, 5)
`Demos:
- Move
$3
Changes an entity's ordinal position with respect to an attached entity
`
( entityType: string,
id: string|number,
relation: string,
src: number,
dest: number
): MoveAttachedAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- relation: the relation key of the collection containing the id to move
- src: the source/starting index of the entity to reposition
- dest: the destination/ending index; where to move the entity toNote:
- if an entity with the
id does not exist, then the action will be ignored
- if the relation is a has-one relation, then the action will be ignored
- if either src or dest is less than 0, then the action will be ignored
- if src greater than the highest index, then the last entity will be moved
- if dest greater than the highest index, then the entity will be move to last positionExample:
`javascript
// in list l1's itemIds array, move itemId at index 2 to index 5
const moveAction = actionCreators.moveAttached('list', 'l1', 'itemIds', 2, 5)
`Demos:
- Move attached
$3
Sorts a top-level entity ids collection
`
(
entityType: string,
compare: (a: T, b: T) => number
): SortAction
`
Parameters:
- entityType: the entity type
- compare: the sorting comparison functionExample:
`javascript
// sort list ids (state.ids.list) by title
const sortAction = actionCreators.sort('list', (a, b) => (a.title > b.title ? 1 : -1))
`Demos:
- Sort
$3
Sorts an entity's attached-ids collection
`
(
entityType: string,
id: string|number,
relation: string,
compare: Compare
): SortAction
`
Parameters:
- entityType: the entity type
- id: the id of an existing entity
- relation: the relation key or relation type of the collection to sort
- compare: the sorting comparison functionNote:
- if an entity with the
id does not exist, then the action will be ignored
- if the relation is a has-one, then the action will be ignoredExample:
`javascript
// in list l1, sort the itemsIds array by by value
const sortAction = actionCreators.sort('list', 'l1', 'itemIds', (a, b) => (a.value > b.value ? 1 : -1))
`Demos:
- Sort attached
$3
Runs a batch of actions in a single reduction
`
(...actions: Action[]): BatchAction
`
Parameters:
- ...actions: Normalized Reducer actions excluding batch and setStateNote:
- each action acts upon the state produced by the previous action
Example:
`javascript
// create list 'l1', then create item 'i1', then attach them to each other
const batchAction = actionCreators.batch(
actionCreators.create('list', 'l1'),
actionCreators.create('item', 'i1'),
actionCreators.attach('list', 'l1', 'itemIds', 'i1'), // 'l1' and 'i1' would exist during this action due to the previous actions
// nested batch-actions are also accepted
actionCreators.batch(
actionCreators.create('item', 'i2'),
actionCreators.create('item', 'i3'),
)
)
`Demos:
- Batch
$3
Sets the normalized state
`
(state: S): SetStateAction
`
Parameters:
- state: the state to setNote:
- intended for initializing state
- does not guard against invalid data
Example:
`javascript
const state = {
entities: {
list: {
l1: { title: 'first list', itemIds: ['i1'] },
l2: {}
},
item: {
i1: { value: 'do a barrel roll', listId: 'l1', tagIds: ['t1'] }
},
tag: {
t1: { itemIds: ['i1'], value: 'urgent' }
}
},
ids: {
list: ['l1', 'l2'],
item: ['i1'],
tag: ['t1']
}
}const setStateAction = actionCreators.setState(state)
`Demos:
- Set state
Selectors API
Each selector is a function that takes the normalized state and returns a piece of the state. Currently, the selectors API is minimal, but are enough to access any part of the state slice so that you can build your own application-specific selectors. $3
Returns an array of ids of a given entity type
`
(state: S, args: { type: string }): (string|number)[]
`
Parameters:
- state: the normalized state
- args.type: the entity type Example:
`typescript
const listIds = selectors.getIds(state, { type: 'item' }) // ['l1', 'l2']
`$3
Returns an object literal mapping each entity's id to its data
`
(state: S, args: { type: string }): Record<(string|number), E>
`
Parameters:
- state: the normalized state
- args.type: the entity type Generic Parameters:
-
: the entity's typeExample:
`typescript
const lists = selectors.getEntities(state, { type: 'item' })
/*
{
l1: { title: 'first list', itemIds: ['i1', 'i2'] },
l2: { title: 'second list', itemIds: [] }
}
*/
`$3
Returns an entity by its type and id
`
(state: S, args: { type: string; id: string|number }): E | undefined
`
Parameters:
- state: the normalized state
- args.type: the entity type
- args.id: the entity idGeneric Parameters:
-
: the entity's typeNote:
- if the entity does not exist, then undefined will be returned
Example:
`typescript
const lists = selectors.getEntity(state, { type: 'item', id: 'i1' })
/*
{ title: 'first list', itemIds: ['i1', 'i2'] }
*/
`Normalizr Integration
The top-level named export fromNormalizr takes normalized data produced by a normalizr normalize call and returns state that can be fed into the reducer.Example:
`js
import { normalize } from 'normalizr'
import { fromNormalizr } from 'normalized-reducer'const denormalizedData = {...}
const normalizrSchema = {...}
const normalizedData = normalize(denormalizedData, normalizrSchema);
const initialState = fromNormalizr(normalizedData);
``Demos:
- Normalizr Integration