@exodus/redux-dependency-injection
v4.1.1
Published
Dependency-injection container for redux modules
Downloads
16,622
Readme
@exodus/redux-dependency-injection
Dependency injection framework on top of redux.
Why
Large redux applications have hundreds of actions, reducers, and especially selectors. Actions don't even pretend to support dependency injection, reducers are not supposed to have any dependencies and though selectors are designed with dependency injection in mind (createSelector(...dependencies, resultFunction)
), their dependencies are typically require
'd / import
'ed, which loses many of the benefits of dependency injection.
Outside of redux land, we have @exodus/dependency-injection
, an IOC container that allows us to define dependencies in a declarative way. It then resolves the dependency graph and instantiates all nodes. This makes it easy to inject fakes during testing, different platform adapters on different platforms and different implementations of the same interface as needed. But you're not here for propaganda...
To adapt this to redux, we need to first support declaring redux components (actions, reducers, selectors) in a way that enables dependency injection. Enter "Redux Module Definitions".
Redux Module Definitions
Here is an example redux module definition written by JK Rowling:
{
id: 'harryPotter',
type: 'redux-module',
// the `initialState` is used to auto-generate a base set of selectors
initialState: {
wand: null,
knownSpells: [],
},
// how events get to the redux module is covered further down
eventReducers: {
harryGoesToOlivanders: (state, payload) => ({ ...state, wand: payload }),
harryLearnsSpell: (state, payload) => ({ ...state, knownSpells: [...state.knownSpells, payload] }),
},
selectorDefinitions: [
// vanilla selector
{
id: 'knowsBasicSpells',
resultFunction: (knownSpells, basicSpells) => basicSpells.every((spell) => knownSpells.includes(spell)),
// the 'knownSpells' dependency selector is auto-generated by @exodus/redux-dependency-injection
// based on `initialState`
dependencies: [
{ selector: 'knownSpells' },
// NOTE: this selector is external to the module
{ module: 'hogwarts' selector: 'basicSpells' },
],
},
// selector factory
{
id: 'knowsSpell',
selectorFactory: (knownSpellsSelector) => (spell) => createSelector(
knownSpellsSelector,
knownSpells =>knownSpells.includes(spell)
),
// the 'knownSpells' dependency selector is auto-generated by @exodus/redux-dependency-injection
// based on `initialState`
dependencies: [{ selector: 'knownSpells' }],
},
],
// ACTIONS ARE (SORT OF) SUPPORTED, BUT NOT RECOMMENDED!
// all your business logic belongs in `features` and `modules`, NOT in redux actions.
// support for these may be removed in the future
actionsDefinition: ({ hogwarts, harryPotter }) => {
return {
helpRonWithHomework: (spell) => (dispatch, getState) => {
if (harry.selectors.knowsSpell(getState())(spell)) {
dispatch(hogwarts.actions.submitHomework({ author: 'Ron' }))
}
}
}
},
eventToActionMappers: {
harryForgetsSpell: (spell) => ({ type: 'HARRY_POTTER_FORGETS_SPELL', payload: spell }),
},
dependencies: [
'hogwarts',
]
}
Let's dive a little deeper.
Selectors
Before
// harry-potter/selectors/knows-basic-spells.js
import { createSelector } from 'reselect'
import basicSpellsSelector from '~/ui/state/hogwarts/selectors/basic-spells'
import harryPotterKnownSpells from './known-spells'
const knowsBasicSpellsSelector = createSelector(
basicSpellsSelector, // e.g. state => state.hogwarts.basicSpells
harryPotterKnownSpells, // e.g. state => state.harryPottern.knownSpells
(basicSpells, knownSpells) => basicSpells.every((spell) => knownSpells.includes(spell))
)
export default knowsBasicSpellsSelector
After
- We don't export the selector itself, but a definition of the selector, so it can be instantiated lazily by the IOC container. This also enables us inject fake
wand
andknownSpells
dependencies in tests. - We can depend on external selectors, such as
basicSpells
from thehogwarts
module, without importing them. - We no longer access global
state
. All data is accessed via other selectors. TheknownSpells
selector declared in dependencies is auto-generated by this library from theinitialState
.
{
...,
selectorDefinitions: [
// vanilla selector
{
id: 'knowsBasicSpells',
resultFunction: (knownSpells, basicSpells) => basicSpells.every((spell) => knownSpells.includes(spell)),
// the 'knownSpells' dependency selector is auto-generated by @exodus/redux-dependency-injection
// based on `initialState`
dependencies: [
{ selector: 'knownSpells' },
// NOTE: this selector is external to the module
{ module: 'hogwarts' selector: 'basicSpells' },
],
},
// selector factory
{
id: 'knowsSpell',
selectorFactory: (knownSpells) => (spell) => knownSpells.includes(spell),
// the 'knownSpells' dependency selector is auto-generated by @exodus/redux-dependency-injection
// based on `initialState`
dependencies: [{ selector: 'knownSpells' }],
}
]
}
Accessing state
As mentioned before for each property in initialState
a separate selector is auto-generated.
You can use these selectors by including them as a dependency in your definitions.
In the following example, two state selectors would be generated, wand
and knownSpells
,
each returning their current value in the state.
const initialState = {
wand: null,
knownSpells: [],
}
export default initialState
If you want access to the whole state slice of a redux module you can use the
special selector MY_STATE
. This is a string constant defined in redux-dependency-injection
.
import { MY_STATE } from '@exodus/redux-dependency-injection'
const someSelectorDefinition = {
// ...
dependencies: [
// ...
{ selector: MY_STATE },
],
}
Selector definition properties
id
required
Identifies this selector when referencing it as a dependency in other selectors. We also use naming conventions for selectors that are part of redux modules.
resultFunction
required when not using
selectorFactory
The resultFunc
as it is defined in reselect.
This function accepts the outputs of selectors defined in dependencies
.
const definition = {
id: 'knowsBasicSpells',
resultFunction: (knownSpells, basicSpells) => basicSpells.every((spell) => knownSpells.includes(spell)),
dependencies: [
{ selector: 'knownSpells' },
{ module: 'hogwarts' selector: 'basicSpells' },
],
}
Example usage in a client of the above definition that belongs to a harryPotter
redux module:
import { selectors } from '#/flux'
selectors.harryPotter.knowsBasicSpells(state)('finite')
selectorFactory
required when not using
resultFunction
This function accepts selectors, listed in dependencies
, instead of their outputs and returns a selector factory function.
It's a more performant alternative to a vanilla selector that returns a function. See our Selectors Best Practices for more details.
import { memoize } from 'lodash'
import { createSelector } from 'reselect'
const definition = {
id: 'createKnowsCharm',
selectorFactory: (knownCharmsSelector) =>
// meomize on charm since it is frequently used
memoize((charm) =>
createSelector(knownCharmsSelector, (knownCharms) => knownCharms.includes(charm))
),
dependencies: [{ selector: 'knownCharms' }],
}
Example usage in a client of the above definition that belongs to a harryPotter
redux module:
import { selectors } from '#/flux'
selectors.harryPotter.createKnowsCharm('expelliarmus')(state)
dependencies
optional
Allows you to inject and use other selectors by providing a list of identifier objects. The entries will passed to resultFunction
or selectorFactory
based on their position.
Selectors that belong to the module itself can simply be referenced by providing the selector's id.
{
//...
dependencies: [
// first arg of resultFunction/selectorFactory
{ selector: 'charmSpells' },
// second arg of resultFunction/selectorFactory
{ selector: 'curseSpells' },
],
}
When referencing a selector from a different module, the module id is required in addition to the selector id.
{
//...
dependencies: [
// another redux module `hogwarts` that has a `basicSpells` selector
{ module: 'hogwarts', selector: 'basicSpells' }
],
}
Actions
Before
import hogwartsActions from '../hogwarts'
import harryKnowsSpell from '#/harry-potter/selectors/knows-spell'
export const helpRonWithHomework = (spell) => (dispatch, getState) => {
if (harryKnowsSpell(getState())(spell)) {
dispatch(hogwartsActions.submitHomework({ author: 'Ron' }))
}
}
After
- We define action creators, rather than instantiate them.
- All dependencies are injected rather than imported, making the action testable and avoiding static dependencies on actions/selectors from other modules.
{
...,
actionsDefinition: ({ hogwarts, harryPotter }) => {
return {
helpRonWithHomework: (spell) => (dispatch, getState) => {
if (harry.selectors.knowsSpell(getState())(spell)) {
dispatch(hogwarts.actions.submitHomework({ author: 'Ron' }))
}
}
}
},
// in case you need to map events to actions for some reason, e.g.
// because some other consumer expects this action. These are mutually exclusive with `eventReducers`
eventToActionMappers: {
harryForgetsSpell: (spell) => ({ type: 'HARRY_POTTER_FORGETS_SPELL', payload: spell }),
},
dependencies: ['hogwarts'],
}
Reducer
Before
function harryPotterReducer(state, { type, payload }) {
switch (payload.type) {
case 'HARRY_POTTER_GOES_TO_OLIVANDERS':
return { ...state, wand: payload.wand }
case 'HARRY_POTTER_LEARNS_SPELL':
return { ...state, knownSpells: [...state.knownSpells, payload.spell] }
default:
return state
}
}
After
Reducers are declared as a mapping from event names to transform functions. This allows to skip the manual step of mapping of event names to redux action types. This is (a bit) opinionated, because Exodus wallets use events to bubble up date from the SDK to the UI.
{
...,
eventReducers: {
harryGoesToOlivanders: (state, payload) => ({ ...state, wand: payload }),
harryLearnsSpell: (state, payload) => ({ ...state, knownSpells: [...state.knownSpells, payload] }),
}
}
There may be cases where it is necessary to ingest data through regular actions as opposed to events originating from the SDK. This can for example be data that is only relevant to the UI. To support this, a redux module can also define actionReducers
:
{
...,
actionReducers: {
USE_DIFFERENT_WAND: (state, payload) => ({ ...state, wand: payload }),
POTTER_CASTS_SPELL: (state, payload) => ({ ...state, spell: 'lumos' ?? payload }),
}
}
Usage
See a complete example in @exodus/wallet-accounts under the /redux
folder. Here's a minimal example:
// flux/index.js
import { setupRedux } from '@exodus/redux-dependency-injection'
import basicSpellsSelector from '~/ui/state/hogwarts/selectors/basic-spells'
const dependencies = [
// external selector, e.g. from a client repo
{
id: 'hogwarts.selectors.basicSpells',
factory: () => basicSpellsSelector,
},
harryPotterReduxModule,
]
const {
reducers: mergedReducers,
initialState: mergedInitialState,
selectors,
handleEvent,
ioc,
} = setupRedux({
// legacy reducers
reducers,
initialState,
dependencies,
logger: console,
})
const store = createStore(mergedReducers, mergedInitialState, enhancers)
const mergedActions = setupActions({ ioc, actions: legacyActionCreators })
const actions = bindAllActionCreators(store, mergedActions)
export { store, actions, selectors }
// to handle an event:
handleEvent('harryLearnsSpell', 'lumos')
// somewhere else in the app
import { selectors } from '#/flux'
selectors.harryPotter.knowsSpell('lumos')
Troubleshooting
Selectors are undefined
This might be caused by a require cycle. If the tests don't report this, use a client's console, e.g. mobile, to easily find what is causing the cycle.
Removing the require cycle should make selectors available again.
Events are not emitted or processed
Make sure an emitter for the event exists, e.g. an observer in the module's lifecycle plugin.
Also ensure that the event name between the emitter and the event reducer in the redux module's definition are the same.