normalized-reducer v0.6.0
Normalized Reducer 
A zero-boilerplate higher-order reducer for managing normalized relational data
š 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
- Action-creators API
- Selectors API
- Normalizr Integration
- LICENSE
The Problem
Managing normalized relational data presents various complexities such as:
- deleting an entity must result in its id being removed from all of its attached entities
- attaching/detaching two related entities requires the id of each entity being added/removed in the other
- implementation of relational behavior differs depending on the cardinality
- most behavior varies based on current state, not just action inputs
- scaling a robust solution without abstraction results in lots of repeated logic
The Solution
Normalized Reducer helps you manage normalized relational state without requiring any reducer/action boilerplate. Simply provide a declarative relational schema, and it gives you the reducers, actions, and selectors to read and write state according to that schema.
Install
yarn add normalized-reducer
Quick Start
Define a schema that describes your data's relationships.
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' } } }More info at: Top-level API > Parameter:
schemaPass in the schema, and get back a reducer, action-creators, action-types, selectors, and empty state.
import makeNormalizedSlice from 'normalized-reducer' const { reducer, actionCreators, actionTypes, selectors, emptyState, } = makeNormalizedSlice(mySchema)More info at: Top-level API > Return Value
Use the
reducerandactionsto update the state. The following example assumes the use ofdispatchfrom either React or React-Redux.With React:
const [state, dispatch] = useReducer(reducer, emptyState);With React-Redux:
const dispatch = useDispatch();Usage:
// 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 valueMore info at: Action-creators API
Use the
selectorsto read state.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
The empty state shape looks like:
{ "entities": { "list": {}, "item": {}, "tag": {} }, "ids": { "list": [], "item": [], "tag": [] } }And a populated state could look like:
{ "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"] } }
Demo
Action demos and usage examples
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 Alternatives
Normalized Reducer is comparable to Redux ORM and Redux Toolkit's entity adapter.
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
Top-level API
The top-level default export is a higher-order function that accepts a schema and an optional namespaced argument and returns a reducer, action-creators, action-types, selectors, and empty state.
makeNormalizedSlice<S>(schema: ModelSchema, namespaced?: Namespaced): {
reducer: Reducer<S>,
actionCreators: ActionCreators<S>,
actionTypes: ActionTypes,
selectors: Selectors<S>,
emptyState: S,
}Example:
import makeNormalizedSlice from 'normalized-reducer';
const {
reducer,
actionCreators,
actionTypes,
selectors,
emptyState,
} = makeNormalizedSlice(mySchema, namespaced);Parameter: schema
The schema is an object literal that defines each entity and its relationships.
interface Schema {
[entityType: string]: {
[relationKey: string]: {
type: string;
reciprocal: string;
cardinality: 'one'|'many';
}
}
}Example:
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.
Parameter: namespaced
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:
const namespaced = actionType => `my-custom-namespace/${actionType}`; If the namespaced argument is not passed in, it defaults to normalized/.
Generic Parameter: <S extends State>
The shape of the state, which must overlap with the following interface:
export type State = {
entities: {
[type: string]: {
[id in string|number]: { [k: string]: any }
}
},
ids: {
[type: string]: (string|number)[]
},
};Example:
interface List {
itemIds: string[]
}
interface Item {
listId: string
}
interface State {
entities: {
list: Record<string, List>,
item: Record<string, Item>
},
ids: {
list: string[],
item: string[]
}
}
const normalizedSlice = makeNormalizedSlice<State>(schema)Return Value
Calling the top-level function will return an object literal containing the things to help you manage state:
reduceractionCreatorsactionTypesselectorsemptyState
reducer
A function that accepts a state + action, and then returns the next state.
reducer(state: S, action: { type: string }): SIn a React setup, pass the reducer into useReducer:
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:
const { reducer } = makeNormalizedSlice(schema)
// compose it with combineReducers
const reduxReducer = combineReducers({
normalizedData: reducer,
//...
})
// or used it as the root reducer
const store = createStore(reducer) actionCreators
An object literal containing action-creators. See the Action-creators API section.
actionTypes
An object literal containing the action-types.
const {
CREATE,
DELETE,
UPDATE,
MOVE,
ATTACH,
DETACH,
MOVE_ATTACHED,
SORT,
SORT_ATTACHED,
BATCH,
SET_STATE,
} = actionTypesTheir values are namespaced according to the namespaced
parameter of the top-level function. Example: normalized/CREATE
selectors
An object literal containing the selectors. See the Selectors API section.
emptyState
An object containing empty collections of each entity.
Example:
{
"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.
create
Creates a new entity
( entityType: string,
id: string|number,
data?: object,
index?: number
): CreateAction Parameters:
entityType: the entity typeid: an id that doesn't belong to an existing entitydata: optional, an object of arbitrary, non-relational dataindex: optional, a number greater than 0
Note:
- the
idshould be a string or number provided by your code, such as a generated uuid - if the
idalready belongs to an existing entity, then the action will be ignored. - if no
datais 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 theattachaction-creator after creating the entity. - if an
indexis provided, then the entity will be inserted at that position in the collection, and if noindexis provided the entity will be appended at the end of the collection.
Example:
// 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:
delete
Deletes an existing entity
( entityType: string,
id: string|number,
cascade?: SelectorTreeSchema
): DeleteActionParameters:
entityType: the entity typeid: the id of an existing entitycascade: optional, an object literal describing a cascading deletion
Note:
- any entities that are attached to the deletable entity will be automatically detached from it.
- pass in
cascadeto delete entities that are attached to the deletable entity
Basic Example:
// deletes a list whose id is 'l1', and automatically detaches any entities currently attached to it
const deletionAction = actionCreators.delete('list', 'l1');Cascade Example:
/*
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:
update
Updates an existing entity
( entityType: string,
id: string|number,
data: object,
options?: { method?: 'patch'|'put' }
): UpdateAction Parameters:
entityType: the entity typeid: the id of an existing entitydata: an object of any arbitrary, non-relational dataoptions.method: optional, whether to partially update or completely replace the entity's non-relational data
Note:
- if an entity with the
iddoes 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 theattachanddetachaction-creators. - if no
methodoption is provided, then it will default to a patch (partial update)
Example:
// 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:
attach
Attaches two existing related entities
( entityType: string,
id: string|number,
relation: string,
relatedId: string|number,
options?: { index?: number; reciprocalIndex?: number }
): AttachActionParameters:
entityType: the entity typeid: the id of an existing entityrelation: a relation key or relation typeattachableId: the id of an existing entity to be attachedoptions.index: optional, the insertion index within the entity's attached-id's collectionoptions.reciprocalIndex: optional, same asoptions.index, but the opposite direction
Note:
- 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:
/*
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:
// 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:
detach
Detaches two attached entities
( entityType: string,
id: string|number,
relation: string,
detachableId: string|number
): DetachAction Parameters:
entityType: the entity typeid: the id of an existing entityrelation: a relation key or relation typedetachableId: the id on an existing entity to be attached
Example:
// detach item 'i1' from tag 't1'
const detachmentAction = actionCreators.detach('item', 'i1', 'tagIds', 't1')Demos:
move
Changes an entity's ordinal position
( entityType: string,
src: number,
dest: number
): MoveActionParameters:
entityType: the entity typesrc: the source/starting index of the entity to repositiondest: the destination/ending index; where to move the entity to
Note:
- if either
srcordestis less than 0, then the action will be ignored - if
srcgreater than the highest index, then the last entity will be moved - if
destgreater than the highest index, then, the entity will be move to last position
Example:
// move the item at index 2 to index 5
const moveAction = actionCreators.move('item', 2, 5)Demos:
moveAttached
Changes an entity's ordinal position with respect to an attached entity
( entityType: string,
id: string|number,
relation: string,
src: number,
dest: number
): MoveAttachedActionParameters:
entityType: the entity typeid: the id of an existing entityrelation: the relation key of the collection containing the id to movesrc: the source/starting index of the entity to repositiondest: the destination/ending index; where to move the entity to
Note:
- if an entity with the
iddoes not exist, then the action will be ignored - if the relation is a has-one relation, then the action will be ignored
- if either
srcordestis less than 0, then the action will be ignored - if
srcgreater than the highest index, then the last entity will be moved - if
destgreater than the highest index, then the entity will be move to last position
Example:
// in list l1's itemIds array, move itemId at index 2 to index 5
const moveAction = actionCreators.moveAttached('list', 'l1', 'itemIds', 2, 5)Demos:
sort
Sorts a top-level entity ids collection
<T>(
entityType: string,
compare: (a: T, b: T) => number
): SortActionParameters:
entityType: the entity typecompare: the sorting comparison function
Example:
// sort list ids (state.ids.list) by title
const sortAction = actionCreators.sort('list', (a, b) => (a.title > b.title ? 1 : -1))Demos:
sortAttached
Sorts an entity's attached-ids collection
<T>(
entityType: string,
id: string|number,
relation: string,
compare: Compare<T>
): SortActionParameters:
entityType: the entity typeid: the id of an existing entityrelation: the relation key or relation type of the collection to sortcompare: the sorting comparison function
Note:
- if an entity with the
iddoes not exist, then the action will be ignored - if the relation is a has-one, then the action will be ignored
Example:
// 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:
batch
Runs a batch of actions in a single reduction
(...actions: Action[]): BatchActionParameters:
...actions: Normalized Reducer actions excludingbatchandsetState
Note:
- each action acts upon the state produced by the previous action
Example:
// 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:
setState
Sets the normalized state
(state: S): SetStateActionParameters:
state: the state to set
Note:
- intended for initializing state
- does not guard against invalid data
Example:
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:
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.
getIds
Returns an array of ids of a given entity type
(state: S, args: { type: string }): (string|number)[]Parameters:
state: the normalized stateargs.type: the entity type
Example:
const listIds = selectors.getIds(state, { type: 'item' }) // ['l1', 'l2']getEntities
Returns an object literal mapping each entity's id to its data
<E>(state: S, args: { type: string }): Record<(string|number), E>Parameters:
state: the normalized stateargs.type: the entity type
Generic Parameters:
<E>: the entity's type
Example:
const lists = selectors.getEntities(state, { type: 'item' })
/*
{
l1: { title: 'first list', itemIds: ['i1', 'i2'] },
l2: { title: 'second list', itemIds: [] }
}
*/getEntity
Returns an entity by its type and id
<E>(state: S, args: { type: string; id: string|number }): E | undefinedParameters:
state: the normalized stateargs.type: the entity typeargs.id: the entity id
Generic Parameters:
<E>: the entity's type
Note:
- if the entity does not exist, then undefined will be returned
Example:
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:
import { normalize } from 'normalizr'
import { fromNormalizr } from 'normalized-reducer'
const denormalizedData = {...}
const normalizrSchema = {...}
const normalizedData = normalize(denormalizedData, normalizrSchema);
const initialState = fromNormalizr(normalizedData);Demos:
LICENSE
MIT