Utility functions and types for normalized reducers architectures
-
A standard protocol to interact with an application's front-end state in order to make CRUD operations on it consistent, regardless of the data that is being stored or consumed. In simple English, this package provides the tools to interact with the state in a similar way to how clients interact with a RESTful API.
-
A set of strongly typed CRUD action interfaces and reducer handlers that enforce a consistent reducer architecture which allows for the robust and reliable scaling of a web application.
-
A framework that simplifies the migration of async logic away from components and into a dedicated layer of redux middleware.
The framework is designed for redux-like architectures where data is stored in reducers and interactions with the stored data happen through actions that get dispatched and hit the reducers.
Many millions of applications with broad ranges of size, complexity and popularity are getting built today with React and Redux because they are the most popular frameworks for that purpose. Unfortunately, there is little consistency in the reducer architecture across the vast ocean of projects that include them in their stack. App state configurations and interaction protocols vary as much as or more than the apps' purposes and business logic.
Many of these projects are so busy sorting out features and aesthetics that they don't allocate much time to addressing the scalabity of their state's design, or the handling of asynchronous logic such as pending AJAX calls to remote APIs.
As the amount and complexity of the app's features grow, their maintenance and scaling becomes increasingly more expensive and cumbersome. This is a result of a growing amount of reducers with a variety of structures for their respective purposes, as well as the non standardized handling of an increasing number of async calls to RESTful servers for a variety of data that the app requires.
This project started while refactoring an app that consumes many related entities from their respective microservices and presents their data in a web interface. As more entities were added to the application, maintaining the reducers that stored their data and the actions that modified them became very inefficient. What's more, the scattered promises that handled the fetching of the entities, sometimes in component's methods and others in redux middleware or custom service classes, made the management and tracking of the ongoing calls difficult to follow and debug. Then, after cleaning up the state and migrating all async logic to a dedicated layer in redux middleware, the benefits of a standard reducer structure and reducer-hitting actions became evident and this set of generic types and functions began to come together as an underlying architecture for scalable react-redux applications.
A standard structure for all reducers and the interactions with them simplifies their maintenance. On the other hand, the normalization of reducers that store entity data simplifies the access-to and control-of both the entities and the relationships between them. Naturally, this results in the ability to better scale the state, regardless of the type of data that is stored and managed in it; be it data entities stored in a database, metadata related to the presentational state of a component in the scope of a session, or any other type of data for any use whatsoever.
$ npm install --save normalized-reducers-utils
or
yarn add normalized-reducers-utils
The initial state of a reducer is created by calling the createInitialState function which takes an optional config object with the following props.
- Type: number | null
- Default: 10
Number of successfully completed requests to keep in the reducer's requests prop
Set to null
to keep all successfully completed requests.
- Type: number | null
- Default: null
Number of failed requests to keep in the reducer's requests prop
Set to null
to keep all failed requests.
- Type (optional):
{
format: string;
timezone: string;
}
- Default: undefined
Format of requests' formatted string timestamps.
Normalized reducers are reducers that have the standard reducer object structure.
This structure is deliberately designed for reducers to store two kinds of data.
Reducers can contain both types of data or only one of the two.
Metadata about the reducer itself. This can be data related to the state of the reducer, regarding the collection of entity data stored in the reducer, or any other information not related to any particular single data entity.
Entity data, e.g. records from a back-end database table / collection
Requests corresponding to request actions to modify a reducer.
Request actions are actions with the '__REQUEST'
suffix in
the action type.
When a request action gets dispatched, a request
object is added to the requests
prop of the reducer.
Requests are indexed by the requestId
included in the
request action and contain the following props about the
requested CRUD operation that should be performed on the reducer.
id
: TherequestId
included in the request actioncreatedAt
: The timestamp at which the request action hits the reducerunixMilliseconds
: Milliseconds since the Unix epochformattedString
(optional): Formatted timestamp according to the format passed in the reducer's config prop
completedAt
: The timestamp at which a success or fail action with the request action'srequestId
hits the reducerunixMilliseconds
: Milliseconds since the Unix epochformattedString
(optional): Formatted timestamp according to the format passed in the reducer's config prop
isPending
: Boolean flag set totrue
when the request action hits the reducer and tofalse
when either a success or a fail action with the request action'srequestId
doesrequestMetadata
: The request metadata object passed with the request actionisOk
(optional): Boolean flag set totrue
when a success action with the request action'srequestId
hits the reducer or tofalse
when a fail action doesentityPks
(optional): String array with the primary keys of the data entities that get modified when a success action with the request action'srequestId
hits the reducerstatusCode
(optional): The status code in a success or a fail action, with the request action'srequestId
, that hits the reducererror
(optional): The error message in a fail action, with the request action'srequestId
, that hits the reducersubRequests
(optional): An array of sub requests, triggered by the CRUD operation carried out as a result of the request action
The reducer's metadata is a standard object with string keys.
The reducer's entity data is indexed by the entities's primary key (PK).
An entity's PK is a concatenated string of the entity's props and/or its
__edges__
, as defined in the reducer's PK schema.
The reducer configuration object with all config params.
As stated above, normalized reducers are often used to store entity data, usually fetched asynchronously from remote RESTful APIs. This is why the reducer contains a requests prop and all interactions with normalized reducers should be initiated with a request action and completed with a success or fail action.
function* wasRequestSuccessful(requestAction: {
type: string;
requestId: string;
}): boolean;
An effect to be placed in a saga that takes a request action and waits for it to be completed
Example:
function* fetchUser1AndThenUser2(): Generator<
CallEffect | PutEffect,
void,
boolean
> {
const getUser1RequestAction = usersCreateActionGetOneRequest('user-1');
yield put(getUser1Action);
const wasGetUser1ActionSuccessful = (yield call(
wasRequestSuccessful,
getUser1RequestAction,
)) as boolean;
if (!wasGetUser1ActionSuccessful) {
console.error('failed to get user 1');
}
const getUser2RequestAction = usersCreateActionGetOneRequest('user-2');
yield put(getUser2RequestAction);
}
function createInitialState<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
initialReducerMetadata: ReducerMetadataT,
initialReducerData: ReducerData<EntityT>,
config?: Partial<ReducerConfig>,
): Reducer<ReducerMetadataT, EntityT>;
Creates a typed initial state for a reducer.
** The EntityT
generic type required by this function is not inferred from
the function's arguments when initialReducerData
is an empty object,
therefore it is recommended that consumers declare the function's generic
types explicitly in function calls.
function normalizeEntityArrayByPk<
EntityT extends Entity,
PkSchemaT extends PkSchema<
EntityT,
PkSchemaFields<EntityT>,
PkSchemaEdges<EntityT>
>,
>(pkSchema: PkSchemaT, entityArray: EntityT[]): ReducerData<EntityT>;
Converts an array of entities into an object, indexed by the entities' PKs.
function createReducerPkUtils<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
PkSchemaT extends PkSchema<
EntityT,
PkSchemaFields<EntityT>,
PkSchemaEdges<EntityT>
>,
>(pkSchema: PkSchemaT): ReducerPkUtils<ReducerMetadataT, EntityT, PkSchemaT>;
Creates an object that contains a reducer's Pk schema as well as PK utility functions.
The PK schema used to create the PK concatenated strings that index the entities in the reducer's data prop.
A function that takes an entity and returns the entity's PK
A function that takes an entity's PK and returns a destructed entity PK
const emptyPkSchema: PkSchema<Entity, [], []> = {
fields: [],
edges: [],
separator: '',
subSeparator: '',
};
An empty PK schema to initialize reducers that don't store entity data.
An abstraction layer of utility functions that handle the manipulation of the reducer's state for CRUD operations on reducer's metadata or on entity data.
Example:
function UsersReducer(
state: UsersReducer = usersInitialState,
action: UsersReducerHittingAction,
): UsersReducer {
switch (action.type) {
case UsersActionTypes.USERS_GET_MANY__REQUEST:
return handleRequest(state, action);
case UsersActionTypes.USERS_GET_MANY__SUCCESS:
return handleSaveWholeEntities(state, action);
case UsersActionTypes.USERS_GET_MANY__FAIL:
return handleFail(state, action);
default:
return state;
}
}
function handleDeleteEntities<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: DeleteEntitiesAction<ActionTypeT, ReducerMetadataT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleFail<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: FailAction<ActionTypeT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleRequest<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
RequestMetadataT extends RequestMetadata,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: RequestAction<ActionTypeT, RequestMetadataT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSavePartialEntities<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SavePartialEntitiesAction<ActionTypeT, ReducerMetadataT, EntityT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSavePartialPatternToEntities<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SavePartialPatternToEntitiesAction<
ActionTypeT,
ReducerMetadataT,
EntityT
>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSaveNothing<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SaveNothingAction<ActionTypeT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSaveWholeReducerMetadata<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SaveWholeReducerMetadataAction<ActionTypeT, ReducerMetadataT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSavePartialReducerMetadata<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SavePartialReducerMetadataAction<ActionTypeT, ReducerMetadataT>,
): Reducer<ReducerMetadataT, EntityT>;
function handleSaveWholeEntities<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
>(
state: Reducer<ReducerMetadataT, EntityT>,
action: SaveWholeEntitiesAction<ActionTypeT, ReducerMetadataT, EntityT>,
): Reducer<ReducerMetadataT, EntityT>;
function createReducerSelectors<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
ReducerPathT extends string[],
ReduxState extends ReducerGroup<ReducerMetadataT, EntityT, ReducerPathT>,
>(
reducerPath: ReducerPathT,
): ReducerSelectors<ReducerMetadataT, EntityT, ReduxState>;
Creates an object that contains reselect selectors to select a reducer's props.
function createReducerHooks<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
ReducerPathT extends string[],
ReduxState extends ReducerGroup<ReducerMetadataT, EntityT, ReducerPathT>,
>(
reducerSelectors: ReducerSelectors<
ReducerMetadataT,
EntityT,
ReducerPathT,
ReduxState
>,
): ReducerHooks<ReducerMetadataT, EntityT>;
Creates an object that contains React hooks to retrieve a reducer's props, as well as individual requests and entities.
type RequestAction<
ActionTypeT extends string,
RequestMetadataT extends RequestMetadata,
> = {
type: ActionTypeT;
requestMetadata: RequestMetadataT;
requestId: string;
};
type DeleteEntitiesAction<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
> = {
type: ActionTypeT;
entityPks: string[];
partialReducerMetadata?: Partial<ReducerMetadataT>;
requestId?: string;
subRequests?: SubRequest[];
statusCode?: number;
};
type SavePartialEntitiesAction<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
> = {
type: ActionTypeT;
partialEntities: ReducerPartialData<EntityT>;
partialReducerMetadata?: Partial<ReducerMetadataT>;
requestId?: string;
subRequests?: SubRequest[];
statusCode?: number;
};
type SavePartialPatternToEntitiesAction<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
> = {
type: ActionTypeT;
entityPks: string[];
partialEntity: Partial<
Omit<EntityT, '__edges__'> & {
__edges__?: Partial<EntityT['__edges__']>;
}
>;
partialReducerMetadata?: Partial<ReducerMetadataT>;
requestId?: string;
subRequests?: SubRequest[];
statusCode?: number;
};
type SavePartialReducerMetadataAction<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
> = {
type: ActionTypeT;
partialReducerMetadata: Partial<ReducerMetadataT>;
requestId?: string;
subRequests?: SubRequest[];
statusCode?: number;
};
type SaveWholeEntitiesAction<
ActionTypeT extends string,
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
> = {
type: ActionTypeT;
wholeEntities: ReducerData<EntityT>;
partialReducerMetadata?: Partial<ReducerMetadataT>;
requestId?: string;
subRequests?: SubRequest[];
statusCode?: number;
flush?: boolean;
};
type FailAction<ActionTypeT extends string> = {
type: ActionTypeT;
error: string;
requestId: string;
statusCode?: number;
};
type DestructedPk<
EntityT extends Entity,
PkSchemaT extends PkSchema<
EntityT,
PkSchemaFields<EntityT>,
PkSchemaEdges<EntityT>
>,
> = {
fields: { [field in PkSchemaT['fields'][number]]: string };
edges: { [edge in PkSchemaT['edges'][number]]: string[] };
};
type PkSchema<
EntityT extends Entity,
FieldsT extends PkSchemaFields<EntityT>,
EdgesT extends PkSchemaEdges<EntityT>,
> = {
fields: FieldsT;
edges: EdgesT;
separator: string;
subSeparator: string;
};
type PkSchemaEdges<EntityT extends Entity> = (keyof EntityT['__edges__'])[];
type PkSchemaFields<EntityT extends Entity> = Exclude<
keyof EntityT,
'__edges__'
>[];
type ReducerPkUtils<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
PkSchemaT extends PkSchema<
EntityT,
PkSchemaFields<EntityT>,
PkSchemaEdges<EntityT>
>,
> = {
pkSchema: PkSchemaT;
getPkOfEntity: (entity: EntityT) => string;
destructPk: (pk: string) => DestructedPk<EntityT, PkSchemaT>;
};
type Entity<ReducerEdgesT extends ReducerEdges> = {
[fieldKey: string]: unknown;
__edges__?: {
[edgeName in keyof ReducerEdgesT]: string[] | null;
};
};
type Reducer<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
> = {
requests: { [requestId: string]: Request };
metadata: ReducerMetadataT;
data: ReducerData<EntityT>;
config: ReducerConfig;
};
type ReducerConfig = {
successRequestsCache: number | null;
failRequestsCache: number | null;
requestsPrettyTimestamps?: {
format: string;
timezone: string;
};
};
type ReducerData<EntityT extends Entity> = {
[entityPk: string]: EntityT;
};
type ReducerEdge = {
nodeReducerPath: string[];
edgeReducerPath?: string[];
edgeSide?: EdgeSide;
};
type ReducerEdges = {
[edgeName: string]: ReducerEdge;
};
type ReducerGroup<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
ReducerPathT extends string[],
> = {
[reducerOrGroup in ReducerPathT[number]]?:
| Reducer<ReducerMetadataT, EntityT>
| ReducerGroup<ReducerMetadataT, EntityT, ReducerPathT>;
};
type ReducerMetadata = {
[metadataKey: string]: unknown;
};
type ReducerPartialData<EntityT extends Entity> = {
[entityPk: string]: Partial<
Omit<EntityT, '__edges__'> & {
__edges__?: Partial<EntityT['__edges__']>;
}
>;
};
enum EdgeSide {
slave,
master,
}
type Request = {
id: string;
createdAt: {
unixMilliseconds: number;
formattedString?: string;
};
completedAt?: {
unixMilliseconds: number;
formattedString?: string;
};
isPending: boolean;
metadata: RequestMetadata;
isOk?: boolean;
entityPks?: string[];
statusCode?: number;
error?: string;
subRequests?: SubRequest[];
};
type RequestMetadata = {
[requestMetadataKey: string]: unknown;
};
type SubRequest = {
reducerName: string;
requestId: string;
};
type ReducerSelectors<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
ReducerPathT extends string[],
ReduxState extends ReducerGroup<ReducerMetadataT, EntityT, ReducerPathT>,
> = {
selectRequests: OutputSelector<
ReduxState,
Reducer<ReducerMetadataT, EntityT>['requests'],
(
res: Reducer<ReducerMetadataT, EntityT>,
) => Reducer<ReducerMetadataT, EntityT>['requests']
>;
selectMetadata: OutputSelector<
ReduxState,
Reducer<ReducerMetadataT, EntityT>['metadata'],
(
res: Reducer<ReducerMetadataT, EntityT>,
) => Reducer<ReducerMetadataT, EntityT>['metadata']
>;
selectData: OutputSelector<
ReduxState,
Reducer<ReducerMetadataT, EntityT>['data'],
(
res: Reducer<ReducerMetadataT, EntityT>,
) => Reducer<ReducerMetadataT, EntityT>['data']
>;
selectConfig: OutputSelector<
ReduxState,
Reducer<ReducerMetadataT, EntityT>['config'],
(
res: Reducer<ReducerMetadataT, EntityT>,
) => Reducer<ReducerMetadataT, EntityT>['config']
>;
};
type ReducerHooks<
ReducerMetadataT extends ReducerMetadata,
EntityT extends Entity,
> = {
useRequest: (
requestId: string,
) => Reducer<ReducerMetadataT, EntityT>['requests'][string] | undefined;
useRequests: (
requestIds?: string[],
) => Partial<Reducer<ReducerMetadataT, EntityT>['requests']>;
useReducerMetadata: () => Reducer<ReducerMetadataT, EntityT>['metadata'];
useEntity: (
entityPk: string,
) => Reducer<ReducerMetadataT, EntityT>['data'][string] | undefined;
useEntities: (
entityPks?: string[],
) => Partial<Reducer<ReducerMetadataT, EntityT>['data']>;
useReducerConfig: () => Reducer<ReducerMetadataT, EntityT>['config'];
};
I developed this framework entirely in my free time and without monetary retribution. You are welcome and encouraged to use it free of charge but if it serves your purpose and you want to contribute to the project, any amount of donation is greatly appreciated!
Paypal | BTC | ETH |
---|---|---|
https://paypal.me/pools/c/8t2WvAATaG | bc1q7gq4crnt2t47nk9fnzc8vh488ekmns7l8ufj7z | 0x220E622eBF471F9b12203DC8E2107b5be1171AA8 |
Thanks to AngSin for his valuable contributions.