@eank/rdx
v1.1.1
Published
RDX is a modular, type-safe redux framework that generates the boilerplate for you.
Downloads
3
Readme
RDX
yarn add redux redux-saga redux-devtools-extension @eank/rdx
The goal of RDX is to let you have predictable state management, with defining that state as the only requirement.
To get started, install the following dependencies:
yarn add redux redux-saga redux-devtools-extension @eank/rdx
or
npm i redux redux-saga redux-devtools-extension @eank/rdx
RDX is a modular, configurable set of tools for redux that can be used as a set of simple tools to help reduce your boilerplate or as a redux framework to take care of almost everything you need to
It is similar to redux-box, but automatically creates actions, reducers, and selectors from state that you provide in any given module.
Under the hood, RDX uses redux, redux-devtools-extension, and redux-sagas. However, use of the latter two is optional and configurable. If you're not concerned about using dev tools or sagas, you can skip the overhead.
RDX is written in typescript, and in most cases, should also be able to maintain type safety for you without effort on your part.
RDX has a peer dependency on redux. You must install redux in addition to RDX.
Sections
- RDX
Modules
A module is a section of your total state.
For performance, it should be ( at most ) two levels deep - though you can go deeper if you want. RDX will recursively create actions, selectors, and reducers for you.
You can use a module for a subapp, for example, or a component in that app.
By providing a prefix and the initial state of your module, you will get RDX to create reducers, selectors, actions, and types.
Creating a Module
import { rdx } from '@eank/rdx'
const bedroomState = {
lightSwitch: 'off',
heatingStatus: {
tooCold: false,
tooWarm: false,
},
}
const bedroomModule = rdx({ prefix: 'bedroom' })(bedroomState)
const {
bedroom: {
types,
actions,
selectors,
reducers,
state, // === bedroomState
},
} = bedroomModule
export { bedroomModule }
What Modules Create For You
Types
each key's name is automatically cased to camelCase for actions, selectors and reducer keys, with CONSTANT_CASE for actions.
Running this will return a list of types that looks like this: ${ prefix }_SET_${ reducerKey }_${ reducerKey }
.
Here's what types
{
'@@rdx/SET_BEDROOM_LIGHT_SWITCH': '@@rdx/SET_BEDROOM_LIGHT_SWITCH',
'@@rdx/SET_BEDROOM_HEATING_STATUS': '@@rdx/SET_BEDROOM_HEATING_STATUS',
'@@rdx/SET_BEDROOM_HEATING_STATUS_TOO_COLD': '@@rdx/SET_BEDROOM_HEATING_STATUS_TOO_COLD',
'@@rdx/SET_BEDROOM_HEATING_STATUS_TOO_WARM': '@@rdx/SET_BEDROOM_HEATING_STATUS_TOO_WARM',
// for all keys, RDX will also provide automatic reset actions.
'@@rdx/RESET_BEDROOM_LIGHT_SWITCH': '@@rdx/RESET_BEDROOM_LIGHT_SWITCH',
'@@rdx/RESET_BEDROOM_HEATING_STATUS': '@@rdx/RESET_BEDROOM_HEATING_STATUS',
// ...
}
actions
For each path of your state, actions will be created for those paths and be of the form set[PascalCasedPath]
.
The actions of the bedroom module look like this:
{
setBedroomLightSwitch: (payload, additionalKeys = {}) => ({
type: '@@rdx/SET_BEDROOM_LIGHT_SWITCH',
payload,
...additionalKeys // optional
}),
resetBedroomLightSwitch: (payload, additionalKeys = {}) => ({
type: `@@rdx/RESET_BEDROOM_LIGHT_SWITCH`,
}), // reset actions are automatically created for you
// ...
}
Selectors
The selectors look like this:
Note: these selectors are not memoized, but can be factored into other libraries such as reselect that do that for you.
{
getBedroomLightSwitch: state => state.bedroom.lightSwitch || (initialState of state.bedroom.lightSwitch),
// etc
getBedroomHeatingStatus,
getBedroomHeatingStatusTooCold,
getBedroomHeatingStatusTooWarm,
}
For any object, selectors will walk down all possible paths and give you a function matching the form get[PascalCasedPath]
.
RDX also exports a selector
util that takes a path of your state and returns a selector function.
Reducers
RDX will return a combined reducer for each module, which will be used by the store.
If you're using a single module as a standalone, you can use the combineReducers
utility function from redux
to combine your reducers. each should be prefixed with the module's prefix. For example:
const reducers = combineReducers({
...appReducers,
bedroom: bedroom.reducer,
})
The initial state will also be returned from the module, and stores created through RDX will expose the combined initial state.
Composing modules
RDX has a utility function called combineModules
that lets you combine as many modules as you would like.
Let's say that you want a second piece of state describing the kitchen.
const kitchenState = {
kitchen: {
empty: true,
},
}
const kitchenModule = rdx({ prefix: 'kitchen' })(kitchenState)
export { kitchenModule }
Now you can combine the modules like so:
import { kitchenModule, bedroomModule, combineModules } from '@eank/rdx'
const modules = combineModules(kitchenModule, bedroomModule)
export { modules }
which will give you this:
{
types: {...bedroomTypes, ...kitchenTypes},
actions: {...bedroomActions, ...kitchenActions},
state: {
bedroom: bedroomState,
kitchen: kitchenState
},
selectors: combinedSelectors,
// note: you need to call combineReducers here manually
// if you aren't using RDX's createStore function.
// this is done so that you can use RDX's
// extendReducers function before feeding it all to the store,
// after which createStore combines everything at the latest possible step.
reducers: { bedroom: bedroomReducer, kitchen: kitchenReducer }
}
This is essentially the same as defining these modules all at once, with a couple of differences:
- reducers will go all the way down the tree structure, if you hit the two level limit before.
- the selectors will have two new methods,
getBedroom
andgetKitchen
that return you the entire state of that module.
This is the only way RDX can combine modules - you can not define modules within each other with rdx
.
At this point, you will have a combined group of modules that allow you to export all of the contained types, actions, selectors, and reducers from one place.
But maybe you'd like RDX to handle creation of the store for you as well. That's described in the next section.
Setting up the store
Using RDX to set up the store will provide you a store with devtools and redux-saga set up. Both are optional.
Note that runSagas
needs to be called right after the store is created. This is to allow freedom in how you organize them. RDX will try to combine them for you, if you haven't already. There are helper functions supplied to help you save some keystrokes with that as well.
This example will also show you how to extend the types, actions, and reducers of RDX's modules before you create a store from them as well.
import {
createStore,
// available if you need them, ie for sagas, which can not use the types that RDX creates automatically.
extendTypes,
createTypes,
createActions,
prefixTypes,
extendActions,
createReducer,
extendReducers
} from "@eank/rdx";
import { modules as combinedModules } from "./modules";
import { sagas as allSagas } from "./sagas";
////////////////////////////////////////////////////////////////
/// optional extension of types, actions, and reducers (selectors take care of themselves)
const customTypes = createTypes`
CUSTOM_TYPE_ONE
CUSTOM_TYPE_TWO
`
const sagaTypes = createTypes`
FETCH_STUFF
FETCH_THINGS
`
modules.types = extendTypes(
modules.types,
customTypes,
sagaTypes
)
modules.actions = extendActions(
modules.actions,
customTypes,
createActions(sagaTypes)
)
const customReducer = createReducer(5, {
[customTypes.CUSTOM_TYPE_ONE](state, action) {
return action.payload
}
})
// will be added as a state key called `custom` at the root level.
modules.reducers = extendReducers(reducers, { custom: customReducer })
////////////////////////////////////////////////////////////////
const {
store,
actions,
types,
reducers,
runSagas,
mapState,
mapActions,
} = createStore({
modules: combineModules(modules) // must be combined via combineModules.
});
runSagas(appSagas) // app sagas should be combined into an array.
export {
store,
mapActions,
mapState,
... // + any of the others, if you'd like.
}
Configuring the store
There's an optional extra set of configs that you can add to the store.
here's the complete list:
{
// the combined modules of your app.
modules: combineModules(module1, module2);
config?: {
// supply these just as you would to applyMiddleware()
middleware?: [...otherAppMiddlewareFunctions],
devtools?: {
// if you don't want devtools, you can disable it here.
enabled: true | false, // process.env.NODE_ENV === 'development' etc. defaults to true.
// if you want to keep it around, you can provide configs
options: {...optionsThatGoStraightToDevtools}
},
sagas?: {
// similarly, you can disable the overhead of redux-saga if you aren't using it.
enabled: true | false, // defaults to true.
options: {...optionsThatGoStraightToReduxSaga}
},
// optional function that you can provide to wrap reducers in your app for setups that require it
// defaults to id
wrapReducersWith?: x => x
}
}
Using Sagas
RDX exposes two helpers to help organize and combine sagas called createSagas
and combineSagas
.
createSagas
createSagas
takes a map of action names, and maps them to saga functions. it then outputs an array
of initialized generators that are ready to be supplied to all
or to combineSagas
.
created sagas can not use the types that are defined by RDX. to make things easier, RDX exports a createTypes
function that's used like below.
import { createTypes, createSagas, combineSagas } from '@eank/rdx'
import { actions } from 'app-actions'
import { put } from 'redux-saga/effects'
// types created from rdx({}) do not work, sagas need their own custom types.
// these must be separated by newline if provided as a template string.
const customTypes = createTypes`
WISH_HAPPY_TRAILS
CURSE_UNHAPPY_TRAILS
`
const sagas = createSagas({
[customTypes.WISH_HAPPY_TRAILS]: function* () {
yield put(actions.setHomePageMessage('Happy trails!'))
// ...
}, // ...
})
you can choose between takeAll and takeEvery for sagas created by createSagas
.
const sagas = createSagas({
// every, latest, both, or neither is fine.
every: {
[customTypes.WISH_HAPPY_TRAILS]: function*() {
yield put(actions.setHomePageMessage("Happy trails!"));
// ...
} // ...
},
latest: {
[customTypes.CURSE_UNHAPPY_TRAILS]: function*() {
yield put(actions.setHomePageMessage("A plague upon your houses"));
}
}
// sagas below these two will default to takeLatest.
[otherType]: function*() {
// ...
// will default to takeLatest(otherType, thisSaga)
}
});
export { sagas }
combineSagas
combineSagas
is used to put sagas together.
Sagas supplied to combineSagas
will be wrapped something akin to the following:
function * () {
try {
yield all(sagas)
} catch(e) {
throw new Error(e)
}
}
So that you don't have to write the boilerplate.
Custom sagas are fine - you do not need to supply them via createSagas
.
In addition, combineSagas
composes with itself
combineSagas(...sagas) === combineSagas(...[combineSagas(...sagas)])
without wrapping everything in another generator, so you can use this to combine them on a per-module basis.
runSagas
runs this by default, so you do not need to call runSagas(combineSagas(sagas))
. All it needs is a list.
if you want to supply an array to combineSagas
, you can - or you can supply them variadically. It will flatten the list of arguments that you provide and check to ensure that they're all generators.
Using mapState and mapActions
To make it simpler to access actions and state, the following actions are provided.
mapActions
takes your set of all actions, accepts a variadic list of strings that match names of actions that you need, and will supply a mapDispatchToProps
function that returns them to you.
mapState
does the same thing with selectors, but also allows you to provide custom names for each selector's get
-prefixed name.
there is a third function that automatically binds both of these to your app's actions + selectors, called createMappers
. It is called by default by RDX, but if you don't want to use these, you can turn that off in configs when creating a store, and they will not be created via combineModules
automatically.
Here is a comparison using the connect
function from react-redux.
import { mapActions, mapState, actions, selectors } from './store'
const mapDispatchToPropsClassic = dispatch => ({
actionOne: (payload) => dispatch(actions.actionOne(payload)),
actionTwo: (payload) => dispatch(actions.actionTwo(payload)),
})
const mapStateToPropsClassic = (state) => ({
lightSwitch: selectors.getBedroomLightSwitch(state)
heatingStatus: selectors.getBedroomHeatingStatus(state)
})
connect(mapDispatchToPropsClassic, mapStateToPropsClassic)
// or, using these,
const mapDispatchToProps = mapActions(
'actionOne',
'actionTwo'
)
// in mapState, the right side are names of selectors.
const mapStateToProps = mapState({
lightSwitch: 'getBedroomLightSwitch',
heatingStatus: 'getBedroomHeatingStatus'
})
connect(mapDispatchToProps, mapStateToProps)
using the hooks interface works as well.
Helper functions and optional features
RDX ships many different functions to help you adopt it piece by piece. Some of these functions are redux related, giving shorthands to help you create or extend actions, types, and reducers. Some aren't redux related at all, but may help you in more general cases of development.
redux-related
RDX ships the following redux-related helpers designed for incremental adoption and extension.
import {
// types related
createTypes,
extendTypes,
// action related
createActions,
extendActions,
createAction,
// reducer related
createAutoReducer,
createReducer,
extendReducers,
replacePartialReducerHandler,
replaceReducerHandler,
spreadReducerHandler,
// state related
createMappers,
createSelectors,
mapActions,
mapState,
mapPaths,
// API related
apiState, // frozen object
apiRequestState, // typesafe function that returns apiState with custom types.
} from '@eank/rdx'
createReducer
RDX ships a function called createReducer which is very similar to the one provided by redux-toolkit.
in addition, it provides a few default reducers to make defining reducers simpler. these are shown in the example below.
const myReducer = createReducer(initialState, {
// (state=initialState, action) => { ...state, ...action.payload };
[TYPE_1]: spreadReducerHandler,
// (state=initialState, action) => action.payload
[TYPE_2]: replaceReducerHandler,
// (state=initialState, action) => isObject(state[key])
// ? { ...initialState, [key]: {...initialState[key], ...action.payload }}
// : {...initialState, [key]: action.payload }}
[TYPE_3]: replacePartialReducerState({ key: `${keyOfInitialState}` })
// or your own function
[TYPE_X](state, action) {
doSomethingTricky(state, action);
return state;
}
});
API helpers
RDX ships two helpers for API requests: apiState
and apiRequestState
.
apiState
is an object with the following properties:
{
dataLoaded: boolean, // defaults to false
fetching: boolean, // defaults to false
error: boolean | object, // defaults to null.
data: object, // defaults to {}
}
const apiState = {
dataLoaded: false,
fetching: false,
error: null,
data: {},
}
apiReducer = createReducer(apiState, {
[`api_request`]: (state) => ({
...state,
fetching: true,
dataLoaded: false,
}),
[`api_success`]: (state, action) => ({
...state,
fetching: false,
dataLoaded: true,
error: null,
data: action.payload ?? {},
}),
[`api_failure`]: (state, action) => ({
...state,
fetching: false,
dataLoaded: false,
error: action.payload ?? null,
}),
[`api_reset`]: () => apiState,
})
when RDX creates the actions for these, they look like and should be called like this:
resetApiReducer()
setApiReducerRequest()
setApiReducerSuccess(responseData)
setApiReducerFailure(errorReturned)
Other util code examples
////////////////////////////////////////////////////////////////
const initialState = {
wow: 'big if true',
apiCall: apiState, // { loaded: false, fetching: false, failed: false, error: {}, data: {} }
}
////////////////////////////////////////////////////////////////
// must be separated by newline if provided as a template string.
// can also be called like: createTypes(['TYPE_1', 'TYPE_2', 'TYPE_3'])
const types = createTypes`
TYPE_1
TYPE_2
TYPE_3
` // returns a key mirrored type object - { TYPE_1: 'TYPE_1' .. TYPE_3 }.
////////////////////////////////////////////////////////////////TYPE_1, AWESOME_TYPE_2, AWESOME_TYPE_3 }
const actions = createActions(types) // { type1, type2, type3 }
const prefixedActions = createActions(prefixedTypes) // { awesomeType1, awesomeType2, awesomeType3 }
////////////////////////////////////////////////////////////////
const selectors = createSelectors(initialState) // { getWow: state => state.wow ?? 'big if true' }
////////////////////////////////////////////////////////////////
const myAction = createAction('wow') // returns a function accepting a payload as its first argument, additional keys as a second object argument, and an optional string id as a third
////////////////////////////////////////////////////////////////
createAutoReducer
if you would like to create reducers automatically, you can use
createAutoReducer(initialState)
which will create a reducer with the same initial state, but listen for these types:
;`@@rdx/SET_WOW``@@rdx/SET_API_CALL``@@rdx/RESET_API_CALL``@@rdx/SET_API_CALL_REQUEST``@@rdx/SET_APP_CALL_SUCCESS``@@rdx/SET_APP_CALL_FAILURE``@@rdx/SET_API_CALL_FETCHING``@@rdx/SET_API_CALL_DATA_LOADED``@@rdx/SET_API_CALL_ERROR``@@rdx/SET_API_CALL_DATA``@@rdx/RESET_SET_API_CALL_FETCHING``@@rdx/RESET_SET_API_CALL_DATA_LOADED``@@rdx/RESET_SET_API_CALL_ERROR``@@rdx/RESET_SET_API_CALL_DATA`
and will crawl down recursively if it's an object with nested keys.
non-redux-related
RDX exports a few generic functions that can help in some situations.
import {
// utils
filter,
get,
getObjectPaths,
hasKeys,
id,
isObject,
keyMirror,
map,
omit,
pipe,
setPath,
tap,
trampoline,
valueOr,
} from "@eank/rdx";
////////////////////////////////////////////////////////////////
id(2) === 2
id(x) === x
////////////////////////////////////////////////////////////////
filter(Boolean)([false, true, 1]); // [true, 1]
////////////////////////////////////////////////////////////////
map(x => x * 2)(2); // [4]
map(x => x * 2)([1, 2, 3]); // [2,4,6]
const loudMap = tap(console.log)(map(x => x * 2));
loudMap(2); // [4] - will console.log the result, as well.
////////////////////////////////////////////////////////////////
const obj = { wow: { big: true } };
const allPaths = getObjectPaths(obj); // ['wow', 'wow.big']
get(obj, allPaths[0], "backupValue") === { big: true };
get(obj, 'what.where.not.there', "backupValue") === "backupValue";
setPath(obj, 'wow.big', false); // { wow: { big: false } }
setPath(obj, 'wow.big.if', true); // { wow: { big: { if: true } } }
////////////////////////////////////////////////////////////////
isObject({}) === true;
isObject([]) === false;
isObject(3) === false; /// ...
hasKeys({}) === false;
hasKeys({ wow: true }) === true;
hasKeys(2) === false; ///
////////////////////////////////////////////////////////////////
// note: these are not transducers - if you are doing a lot of transformations this way,
// every step will iterate over the list again.
pipe(
map(triple),
filter(isEven)
)([1, 2, 3]) === [6];
////////////////////////////////////////////////////////////////
keyMirror([1, 2, 'cool']) === {
'1': '1',
'2', '2',
'cool': 'cool'
}
////////////////////////////////////////////////////////////////
valueOr(null, 2) === 2
valueOr(undefined, 2) === 2
valueOr(false, 2) === false
valueOr('anything that is not null or undefined', 2) === 'anything that is not null or undefined'
omit(['id'], {
id: 1,
name: 'Biff'
}) === { name: 'Biff' }
Usage with Typescript
To have typescript check state for you within RDX and allow editors to autocomplete from it, do the following
When you use rdx
, give it the prefix as a literal type. It's a little bit cumbersome, but it's necessary to feed the type information downstream.
const createKitchenModule = rdx<'kitchen'>({
prefix: 'kitchen',
})
When you supply it with a state object, it will infer the type.
createKitchenModule(kitchenModuleState) // type of kitchenState
When you use combineModules
, you must supply it the shape of your state. Example:
import { bedroomModule, bedroomModuleState } from './modules/bedroom'
import { kitchenModule, kitchenModuleState } from './modules/kitchen'
import { combineModules } from '@eank/rdx'
type AppState = {
bedroom: typeof bedroomModuleState // or the type / interface you defined for it
kitchen: typeof kitchenModuleState // or the type / interface you defined for it
}
const modules = combineModules<AppState>(bedroomModule, kitchenModule)
Say you have a module that contains api state. if you are using the API utils from @eank/rdx, you can use the apiRequestState
helper to create a module that has the api state.
type NamesResponseData = string[]
type NamesResponseError = { message: string }
const namesData = apiRequestState<NamesResponseData, NamesResponseError>()
const apiModule = rdx<'api'>({
prefix: 'api',
})({
names: namesData, // will be type safe.
})
in typescript, some functions are enhanced with type information.
get({ obj: { wow: true } }, 'obj.wow') // typescript will infer this to be of type boolean.
selector('obj.wow')({ obj: { wow: true } }) // typescript will infer this to be of type boolean.
selector<AppState, 'obj.wow'>('obj.wow') // will also infer. the path is necessary as type info.
mapActions(`action1`, `action2`...) // typescript will infer the currently available actions and catch you on any mispellings.