Dependency Injection in a redux action creator
React-thunk supports passing an arbitrary object to a thunk using withExtraArgument
. You can use this to dependency-inject a service object, e.g.:
const bluetoothService = require('./blueToothService');const services = { bluetoothService: bluetoothService};let store = createStore(reducers, {}, applyMiddleware(thunk.withExtraArgument(services)));
Then the services are available to your thunk as a third argument:
function startDeviceScan() { return function (dispatch, getstate, services) { // ... services.bluetoothService.startDeviceSearch((device) => { dispatch(addDevice(device)); }); }}
This is not as formal as using a dependency-injection decorator in Angular2 or creating a separate Redux middleware layer to pass services to thunks---it's just an "anything object" which is kind of ugly---but on the other hand it's fairly simple to implement.
You can use a redux middleware that will respond to an async action. In this way you can inject whatever service or mock you need in a single place, and the app will be free of any api implementation details:
// bluetoothAPI Middlewareimport bluetoothService from 'bluetoothService';export const DEVICE_SCAN = Symbol('DEVICE_SCAN'); // the symbol marks an action as belonging to this api// actions creation helper for the middlewareconst createAction = (type, payload) => ({ type, payload});// This is the export that will be used in the applyMiddleware methodexport default store => next => action => { const blueToothAPI = action[DEVICE_SCAN]; if(blueToothAPI === undefined) { return next(action); } const [ scanDeviceRequest, scanDeviceSuccess, scanDeviceFailure ] = blueToothAPI.actionTypes; next(createAction(scanDeviceRequest)); // optional - use for waiting indication, such as spinner return new Promise((resolve, reject) => // instead of promise you can do next(createAction(scanDeviceSuccess, device) in the success callback of the original method bluetoothService.startDeviceSearch((device) => resolve(device), (error) = reject(error)) // I assume that you have a fail callback as well .then((device) => next(createAction(scanDeviceSuccess, device))) // on success action dispatch .catch((error) => next(createAction(scanDeviceFailure, error ))); // on error action dispatch};// Async Action Creatorexport const startDeviceScan = (actionTypes) => ({ [DEVICE_SCAN]: { actionTypes }});// ACTION_TYPESexport const SCAN_DEVICE_REQUEST = 'SCAN_DEVICE_REQUEST'; export const SCAN_DEVICE_SUCCESS = 'SCAN_DEVICE_SUCCESS'; export const SCAN_DEVICE_FAILURE = 'SCAN_DEVICE_FAILURE';// Action Creators - the actions will be created by the middleware, so no need for regular action creators// Applying the bluetoothAPI middleware to the storeimport { createStore, combineReducers, applyMiddleware } from 'redux'import bluetoothAPI from './bluetoothAPI';const store = createStore( reducers, applyMiddleware(bluetoothAPI););// Usageimport { SCAN_DEVICE_REQUEST, SCAN_DEVICE_SUCCESS, SCAN_DEVICE_FAILURE } from 'ACTION_TYPES';dispatch(startDeviceScan([SCAN_DEVICE_REQUEST, SCAN_DEVICE_SUCCESS, SCAN_DEVICE_FAILURE]));
You dispatch the startDeviceScan async action, with the action types that will be used in the creation of the relevant actions. The middleware identifies the action by the symbol DEVICE_SCAN. If the action doesn't contain the symbol, it dispatches it back to the store (next middleware / reducers).
If the symbol DEVICE_SCAN exists, the middleware extracts the action types, creates and dispatches a start action (for a loading spinner for example), makes the async request, and then creates and dispatches a success or failure action.
Also look at the real world redux middle example.
Can you wrap your action creators into their own service?
export function actionCreatorsService(bluetoothService) { function addDevice(device) { return { type: 'ADD_DEVICE', device } } function startDeviceScan() { return function (dispatch) { // The Service invokes the given callback for each found device bluetoothService.startDeviceSearch((device) => { dispatch(addDevice(device)); }); } } return { addDevice, startDeviceScan };}
Now, any clients of this service will need to provide an instance of the bluetoothService. In your actual src code:
const bluetoothService = require('./actual/bluetooth/service');const actionCreators = require('./actionCreators')(bluetoothService);
And in your tests:
const mockBluetoothService = require('./mock/bluetooth/service');const actionCreators = require('./actionCreators')(mockBluetoothService);
If you don't want to specify the bluetooth service every time you need to import the action creators, within the action creators module you can have a normal export (that uses the actual bluetooth service) and a mock export (that uses a mock service). Then the calling code might look like this:
const actionCreators = require('./actionCreators').actionCreators;
And your test code might look like this:
const actionCreators = require('./actionCreators').mockActionCreators;