@redux-toolset/slices v0.1.2
Redux Slices
This package is yet another attempt to simplify redux state management for larger applications.
It allows to define comprehensive state shape descriptors
(slices
) for individual modules (logical units) of your application.
Slices
are used to automatically generate action creators and selectors on the module level.
On the application level, Slices
are combined into a single state shape tree
that is used to generate reducers and initialize Redux store.
Install via npm install @redux-toolset/slices
or yarn add @redux-toolset/slices
Warning
This is experimental software, created by a single person learning react
/redux
.
It may turn out to be conceptually wrong and there may be better/more correct ways to do the same.
The API may change without notice. Please do not rely on this for production use.
Comments, suggestions, PRs and critique are welcome.
Features
- application state tree is assembled from modular
slices
pertaining to logical units of the application - multi-level state trees are supported
slice
can be mounted at any node of the state tree, see Mount points- state mutation logic is fully defined in the
slice
descriptor - strongly typed selectors and action creators are natively supported in Typescript
- ensures uniqueness of action types and state keys across the application (at runtime)
- generates transparent and expressive action names (flattened path to the
action creator
'sslice node
) - supports dynamic addressing of state nodes in reducer, see Resolvers
- supports direct state mutation in reducers via immer
- supports different state scopes for reducers, see Reducer scopes
- supports one-to-many, one-to-one and many-to-one relations between actions and reducers, see Subscriptions
- automatically routes actions to the subscribed reducers, avoiding the overhead of invoking every reducer for every action
- merges all
slices
used in the application into a singlestate shape tree
to build reducer map consumable bycombineReducers()
- merges initial state definitions in individual
slices
into an object that defines initial state for the global state tree, passable tocreateStore()
- reduces boilerplate when designing action creators, selectors and reducers
- works with popular Redux tools and middleware (reselect, redux-thunk, redux-promise-middleware and others) out of the box.
- strives to conform to the original Redux vision, concepts and best practices
- footprint is ~ 6 Kb
Entities
Slices
- each
slice
is defined and owned by a separate logical unit of the application (module, library, package etc.) slice
is at the root of the tree ofslice nodes
. Aslice
is itself aslice node
.slice node
describes a single node in the application state tree and may be nested (contained in anotherslice node
).slice
has the following properties:name
- corresponds to the node name in the state treeinitial
- defines initial values for the corresponding node and all its sub-nodesmountPoint
- path to the node in the state tree where thisslice
is to be mounted. If not specified, thisslice
will be one of theroot slices
- A
slice node
has the following properties:reducer
- reducer descriptor for thisslice node
resolver
- allows to dynamically address state nodes in reducers, see resolversactions
- an object whose keys correspond to action name and values represent reducer to handle the action, orundefined
if the action has no corresponding reducernodes
- an object whose keys correspond to the sub-node names in the state tree, and values areslice nodes
- action creators and selectors are automatically generated from the
slice
, seecompileSlice()
Templates
template
is a concise description of aslice
- if
slice
is a machine code,template
is a source code, for better readability and expressiveness - it is not necessary to use
templates
, one is free to define slices directly template
is always compiled into aslice
template
is an object where the meaning of entries depend on the type of the keys- if the key is a
string
or anumber
, value describesslice node
with the same name (and, consequently, node in thestate tree
). - if the key is a special symbol
MetaKey
, value describes properties of thisslice node
(that is,reducer
,resolver
andactions
, see above)
- if the key is a
- in the absence of
MetaKey
, the following defaults are used for theslice node
:reducer
=payloadReducer
- all other properties are undefined
Reducer Descriptors
Reducer Descriptor
is defined as:
interface IReducerDetails {
reducer: Reducer;
pure: boolean;
scope: ReducerScope;
subscribe?: ActionList;
}
reducer
is areducer function
as defined in Redux documentationpure
is true if reducer is guaranteed to be a pure function. If not specified explicitly, and the reducer is a user-defined function,pure
is assumed to befalse
and the reducer may be wrapped in the immer'sproduce
scope
is one ofReducerScope.Slice
orReducerScope.Node
, see explanation belowsubscribe
allows this reducer to react to action(s) other than the default action for this reducer (see Action Creators and Subscriptions)
Reducer Scopes
Reducer scope defines which specific node of the state tree will be passed to the reducer, and is expected to be returned from the reducer.
As slice nodes
have tree-like structure, reducers may be defined on deeply nested nodes.
Sometimes it may be desired for such reducer to have access to the slice
's entire state tree, at other times it is enough to act upon the slice node
's branch.
Use ReducerScope.Node
in the latter case and ReducerScope.Slice
in the former.
Payload Reducer
Payload reducer is a defined as:
const payloadReducer: IReducerDetails = {
reducer: (_, action) => action.payload,
pure: true,
scope: ReducerScope.Node
};
basically it is a setter for a given state node, and the value to be set is taken from the action
's payload. Notice that
payload reducer has ReducerScope.Node
scope, meaning that it receives state traversed to the slice node
where the reducer is defined.
One special case for payload reducer is when its corresponding action is dispatched with undefined
payload. In this case,
the node is deleted from the state tree instead of setting it's value in the parent object to undefined
.
Example
Given the following state shape:
interface IInvoiceItemState {
name: string;
quantity: number;
price: number;
}
interface IInvoiceState {
date: string;
items: { [key: string]: IInvoiceItemState }; // key corresponds to the ID of the item
memo: string;
}
type InvoicesState = { [key: string]: IInvoiceState }; // key corresponds to the ID of the invoice
we need:
- actions to add new invoice, update or delete existing invoice by its ID
- actions to add new item in the invoice, update or delete existing item by invoice ID and item ID
- selector to obtain the snapshot of invoices in the state tree
- selector to compute total amount of the invoice
First, we define template
for our to-be-generated slice
:
const InvoicesTemplate: ISliceTemplate = {
invoice: {
[MetaKey]: {
resolver: (meta: IInvoiceActionMeta) => meta.invoiceId
},
items: {
item: {
[MetaKey]: {
resolver: (meta: IInvoiceItemActionMeta) => meta.itemId
},
quantity: {}
}
},
memo: {}
}
};
MetaKey
is a special symbol that allows to specify properties for aslice node
. In this case, we useMetaKey
to define Resolver for theinvoice
andinvoice.items.item
nodes- we use two additional types
IInvoiceActionMeta
andIInvoiceItemActionMeta
, defined as:
interface IInvoiceActionMeta {
invoiceId: string;
}
interface IInvoiceItemActionMeta extends IInvoiceActionMeta {
itemId: string;
}
We will later make use of these types when dispatching actions for specific invoices and items.
The last step before we compile our template is to declare action creators. This step is entirely optional and is needed only for compile-time type checking.
type InvoicesAction = ActionCreator<InvoicesState> & {
invoice: ActionCreator<IInvoiceState | undefined, IInvoiceActionMeta> & {
items: ActionCreator<
{ [key: string]: IInvoiceItemState },
IInvoiceItemActionMeta
> & {
item: ActionCreator<
IInvoiceItemState | undefined,
IInvoiceItemActionMeta
> & {
quantity: ActionCreator<number, IInvoiceItemActionMeta>;
};
};
memo: ActionCreator<string, IInvoiceActionMeta>;
};
};
Notice how action creators repeat slice
inner structure.
Finally, proceed with compilation, generate actions, selector, reducers and create Redux store:
const { slice, action, select } = compileSlice<InvoicesState, InvoicesAction>(
'invoices',
InvoicesTemplate
);
const { reducers, initial } = buildReducer(mergeSlices([slice]));
const rootReducer = combineReducers(reducers);
const store = createStore(rootReducer, initial);
Now we can:
store.dispatch(action.invoice({ date: .., items: {...} }, { invoiceId: <replace_by_invoice_id> }));
to add or update an invoice by its IDstore.dispatch(action.invoice(undefined, { invoiceId: <replace_by_invoice_id> }));
to remove an invoice by its ID (see special case inpayloadReducer
description)store.dispatch(action.invoice.items.item({name:...}, { invoiceId: <replace_by_invoice_id>, itemId:<replace_by_item_id> }));
to add or update an invoice item by its IDstore.dispatch(action.invoice.items.item(undefined, { invoiceId: <replace_by_invoice_id>, itemId:<replace_by_item_id> }));
to remove an invoice item by its IDselect(store.getState)
to obtainInvoicesState
object- and we can either dynamically compute total amount of the invoice like this:
const selectInvoiceAmount = (getState: any, invoiceId: string) =>
Object.values(select(getState)[invoiceId]?.items || {}).reduce(
(acc, item) => acc + item.price * item.quantity,
0
);
const amount = selectInvoiceAmount(store.getState, '1');
- or use reselect to memoize computation (recommended):
const selectInvoiceAmount = createSelector(select, invoices =>
memoize((invoiceId: string) =>
Object.values(invoices[invoiceId]?.items || {}).reduce(
(acc, item) => acc + item.price * item.quantity,
0
)
)
);
const amount = selectInvoiceAmount(store.getState)('1');
See invoices-example.spec.ts for a full example
Resolvers
In the example above, the nodes invoice
and invoice.items.item
need invoice ID and invoice item ID to address respective state entry.
For example, if we have 1 invoice with 2 items, our state tree will look like:
{
"invoices": {
"1": {
"date": "2000-1-1",
"items": {
"1": {
"name": "pen",
"quantity": 1,
"price": 0.5
},
"2": {
"name": "pencil",
"quantity": 2,
"price": 0.3
}
}
}
}
}
This poses no problem for the ReducerScope.Slice
reducers. However, state traversal for the ReducerScope.Node
reducer defined,
for example, at the level of the invoice item, requires both invoice ID and invoice item ID, and a way to extract these IDs from the action
.
Resolvers serve this purpose exactly. They take action.meta
as a parameter and return value to be used as index in the state object on the level where the resolver is defined.
So if we want to change the second item's quantity in the first invoice from 2 to 3, we can do:
store.dispatch(
action.invoice.items.item.quantity(3, { invoiceId: 1, itemId: 2 })
);
Action Creators
Action creator
is basically a function that returns an action with a pre-defined type
, and other properties (payload
, meta
, error
) passed as action creator's arguments.
Action creators are automatically generated for each slice node
, and action type
is computed as a flattened path to that slice node
.
Our definition of action creator is very similar to the Redux Action Creator with the following additions:
- action's
type
is always a flattened path to theslice node
corresponding to theaction creator
. action creator
'stoString()
method returns action'stype
- action creator is a function, but is also an object with keys mapping nested action creators.
This may seem like a poor design decision (and indeed it may be), but it produces expressive and readable action references.
There is a side effect:
action creators
should not have names overlapping standard properties defined for the Javascriptfunction
object:length
,name
,apply
, and others. The current workaround is to add an underscore ('') to the action name if the clash is detected. For example, the pathinvoices/invoice/items/item/name
will generate action creator `action.invoices.invoice.items.item.name`.
In the example above, the following action creators are generated:
action.invoices(payload:InvoicesState); // creates action {type:'invoices', payload}
action.invoices.invoice(payload:IInvoiceState|undefined, meta:IInvoiceActionMeta); // creates action {type:'invoices/invoice', payload, meta}
action.invoices.invoice.memo(payload:string, meta:IInvoiceActionMeta); // creates action {type:'invoices/invoice/memo', payload, meta}
action.invoices.invoice.items(payload:{ [key: string]: IInvoiceItemState }, meta:IInvoiceActionMeta); // creates action {type:'invoices/invoice/items', payload, meta}
action.invoices.invoice.items.item(payload:IInvoiceItemState|undefined, meta:IInvoiceItemActionMeta); // creates action {type:'invoices/invoice/items/item', payload, meta}
action.invoices.invoice.items.item.quantity(payload:number, meta:IInvoiceItemActionMeta); // creates action {type:'invoices/invoice/items/item/quantity', payload, meta}
Subscriptions
Redux encourages to avoid one-to-one mapping between reducers and actions, (see this, for example).
Naturally, an action
should not be viewed as a simple setter for a node in the state tree. An action
is rather an event
that may be handled by one or many reducers to change respective
parts of the state tree. It may also not be handled by any reducer at all. Original Redux implementation simply forwards every action to every reducer, and it is up to the reducer to decide how
to handle or ignore the action. Slightly different approach is suggetsed here. By default, every action generated for the slice node
is 1-to-1 mapped to the Payload reducer,
and the latter acts as a simple setter. But if the reducer function
is specified explicitly in the Reducer descriptor, we can subscribe it to actions pertaining to any slice
,
or actions that aren't related to slices at all.
Just need to add action types to the subscribe
array in the Reducer descriptor.
Additionally, we can define extra action creators that have no direct relationship to to the state tree at any level of the slice
using the sliceNode.actions
field.
Selectors
Selector
is a function that receives the snapshot of the entire state tree and returns the part of the state pertaining to the slice
. Selector
function is generated by compileSlice()
.
if function is passed to the Selector
, it will call that function with any extra arguments passed to the Selector
function.
For example, select(store.getState())
and select(store.getState)
yields the same result.
Mount Points
Slices
have an optional mountPoint
property that allows to associate the slice
with arbitrary node of the application state tree.
The most common use case it to group different slices under a single parent that is persisted across web page reloads, while the rest of the state tree is re-created from default on every reload.
API
Primary functions
compileSlice():
function compileSlice<S, A = ActionCreatorNode>(
name: StateNodeName,
template: ISliceTemplate,
initial?: S,
mountPoint?: StateNodePath
): {
slice: ISlice<S>;
select: Selector<S>;
action: A;
};
Compiles Template and returns:
- compiled
slice
(see Slices), - generated
selector
for thisslice
(see Selectors), - generated
action creators
for this slice (see Action creators) This function should be used on the module level. Compiledslice
may be exported from the module and merged with otherslices
when the Redux store is created.
This function takes 2 steps: first, it callscompileTemplate()
, and thengenerateSelectorAndActions()
.
generateReducers():
function generateReducers(
slices: Array<ISlice>,
options: IBuildOptions = defaultBuildOptions
): {
reducers: ReducersMapObject;
initial: any;
};
Merges slices
and generates:
- map of reducers to be passed to
combineReducers()
- initial state tree that is a merger of
slices'
initial states. Maybe be passed as the second argument tocreateStore()
This function is used on the application level, right before the Redux store is created.
This function takes 2 steps: first, it calls mergeSlices()
, and then buildReducers()
.
The optional options
parameter is defined as:
interface IBuildOptions {
/**
* wrap impure reducers with immer.produce()
*/
immer?: boolean;
}
const defaultBuildOptions: IBuildOptions = {
immer: true
};
Auxiliary functions
compileTemplate():
function compileTemplate<S>(
name: StateNodeName,
template: ISliceTemplate,
initial?: S,
mountPoint?: StateNodePath
): ISlice<S>;
Compiles Template and returns compiled slice
(see Slices).
Consider using compileSlice()
instead.
generateSelectorAndActions():
function generateSelectorAndActions<S, A = ActionCreatorNode>(
slice: ISlice<S>
): {
select: Selector<S>;
action: A;
};
Generates selector
(see Selectors) and action creators
for this slice (see Action creators).
Consider using compileSlice()
instead.
mergeSlices():
function mergeSlices(slices: Array<ISlice>): IStateShape;
Merges provided slices
into a single tree describing the shape of the entire state tree.
The output of this function is consumed by buildReducers()
, but may also be used to visualize the entire state shape during debugging.
Consider using generateReducers()
instead.
buildReducers()
:
export function buildReducers(
root: IStateShape,
options: IBuildOptions = defaultBuildOptions
): {
reducers: ReducersMapObject;
initial: any;
};
Takes state shape descriptor
built from individual slices
and generates:
- map of reducers to be passed to
combineReducers()
- initial state tree that is a merger of every
slice
's initial state. Should be passed tocreateStore()
Consider usinggenerateReducers()
instead.