Currently to dispatch an action we use what is called an "action creator" an action creator is simply a function that returns an "action" that can be provided to dispatch()
on a Redux store. So to load a user in Redux we might do something like this:
export const loadUserById = (id) =>
(dispatch, getState) => {
// First we let reducers know that we are requesting a certain user.
dispatch({
type: USER__LOAD_STARTED,
payload: {
id: id
},
});
// Actually fetch the user by id using some sort of request library
Api.fetchUser(id).then(
response => {
// On successful response let the reducers hand the user
dispatch({
type: USER__LOAD_FINISHED,
payload: { user: response },
});
},
() => {
// On failure let the reducers handle a failure.
dispatch({
type: AppConstants.USER__LOAD_FAILED,
payload: {
id,
},
});
}
);
}
This would be used in a component like this:
class UserProfile extends React.Component {
componentDidMount() {
/**
* Invoke loadUserById
*/
this.props.loadUserById(this.props.userId);
}
render() {
if (!this.props.user) return <UserProfileLoading />
return (
<UserProfile user={this.props.user} />
);
}
}
export default connect((state, { userId }) => ({
user: selectUserById(state, userId)
}, {
loadUserById,
})(UserProfile);
This leverages redux-thunk to dispatch a sequence of asynchronous actions and buys a lot of really great benefits such as:
- We can aggregate, throttle and buffer effects. This can help us optimize things like network requests or store updates without leaking those details into the application.
- Common way of looking up in a cache to determine if we need to actually make the request or if the user in memory is fresh enough.
- All the other rad Redux wins that come from using a log and a Single State Tree that I documented here.
However there are still some concerns worth addressing:
Unit testing Action Creators still involves mocking because that is where your IO & impurity occurs. It is called mocking because the computer literally makes a mockery of the pain & futility of testing with mocks.
Action creators can potentially become coupled to a particular runtime or framework... For example if action creators use $http, they are coupled to angular.js. If side effects are written in a framework agnostic way such as using action creators above and a standard like fetch()
. They are still are triggered by invoking an imperative action (calling the loadUserById()
function. Let me illustrate.
Not only is this coupled to the browsers UI thread, it is also coupled to the angular dependency injector and runtime.
// Awww man, now we can only dispatch this effect from an angular injector aware consumer.
angular.service('UserService', function($http) {
this.loadUserById = (id) => (dispatch) => {
dispatch({
type: USER__LOAD_STARTED,
payload: {
id: id
},
});
// Angular coupling :-(
$http.get(`/users/${id}`).then(
response => {
dispatch({
type: USER__LOAD_FINISHED,
payload: { user: response },
});
},
() => {
dispatch({
type: AppConstants.USER__LOAD_FAILED,
payload: {
id,
},
});
}
);
});
});
Dispatching the action creator in UI thread is great and something we need to be able to do however, ONLY being able to dispatch it as a function like this requires additional machinery to trigger the same effects in another thread such as a worker or from the server using server sent events or web sockets.
An example of this machinery might look like this:
// Listen for web socket event
socket.onEvent((event) => {
if (event.type === USER__FETCH_REQUESTED) {
// Manually map and dispatch to correct action creator
store.dispatch(loadUserById(event.payload.userId));
}
})
We would have to author this machinery of mapping a server event to an effect from a worker thread as well. So in a sense... dispatching these action creators are "coupled" to the main thread in the browser runtime.
One solution to these problems is lift effects into an abstraction that is soley responsible for side effects. I'm going to center the following on redux-saga though you can accomplish the same in a more reactive style using redux-observable.
Instead of your action creator handling the side effects it creates a description of the type of work you need to happen, or rather the invariants that you require.
export const loadUserById = (id) => ({
type: USER__FETCH_REQUESTED,
payload: { id },
});
The actual side effects are managed via a saga as redux middleware.
import { call, put, takeEvery, takeLatest } from 'redux-saga/effects'
import Api from '...'
function* fetchUser(action) {
yield put({
type: USER__LOAD_STARTED,
payload: { id }
});
try {
const user = yield call(Api.fetchUser, action.payload.id);
yield put({
type: USER__LOAD_FINISHED,
payload: {user }
});
} catch (e) {
yield put({
type: USER__LOAD_FAILED, payload: { id }
});
}
}
/**
* Starts fetchUser on each dispatched `USER_FETCH_REQUESTED` action.
* Allows concurrent fetches of user.
*/
function* fetchUserSaga() {
yield takeEvery(USER__FETCH_REQUESTED, fetchUser);
}
export default fetchUserSaga;
This is then hooked into the redux store above your interface code:
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import reducer from './reducers'
import mySaga from './sagas'
// create the saga middleware
const sagaMiddleware = createSagaMiddleware()
// mount it on the Store
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
)
// then run the saga
sagaMiddleware.run(fetchUserSaga)
So now your code dispatches a simple declarative action that does no I/O itself. This code should look familiar because it hasn't changed. The primary difference is that action your code dispatches doesn't do I/O it asserts that it would like some I/O to be done and the saga does the I/O.
class UserProfile extends React.Component {
componentDidMount() {
/**
* Invoke loadUserById
*/
this.props.loadUserById(this.props.userId);
}
render() {
if (!this.props.user) return <UserProfileLoading />
return (
<UserProfile user={this.props.user} />
);
}
}
export default connect((state, { userId }) => ({
user: selectUserById(state, userId)
}, {
loadUserById,
})(UserProfile);
I can almost your mind saying to me.
Ok you putz. That seems like indirection with no gains!
I hear you, stick with me while we focus on gains!
Effects are handled out side of any framework aware concepts, in fact your framework code is only aware of the single abstraction is was already aware of... namely the redux store. This is subtle and amorphous so lets try to reify it a bit. To dispatch same same effect in Angular and React we can avoid the potential pitfall of having multiple request libraries or other dependencies in action creators. To load a user in Angular, React or Vue we dispatch the intention:
dispatch(loadUserById(id));
// Or inlined
dispatch({ type: USER__FETCH_REQUESTED, payload: { id });
This makes its way to the "Effect Manager" and the saga does the work. So in whether Angular, angular.js, React or Vue we just dispatch a flat data object the effect handling is the same. This constraint of having an effect manager discourages the idea of framework coupled effects such as using an angular service to do I/O which would be awkward to call in React since $http promises are angular coupled.
The neat thing here is that websockets can trigger effects by dispatching the same simple action or intention. So when a web service would like to invalidate a clients user object.
// One hook to rule them all, now web sockets can trigger any effect the client can!
// They also go through the same process & optimizations as the client.
socket.onEvent((event) => store.dispatch(event));
Lets take a look at the code in the saga that tells the effect manager to hit the network.
const user = yield call(Api.fetchUser, action.payload.id);
The key distinction here is that we don't actually call the fetchUser function with action.payload.id. We ask the effect manager to do that for us, if we look at what is yielded here it looks like this:
{
CALL: {
fn: Api.fetchUser,
args: [1]
}
}
So we don't actually perform the call? Nope. The effect manager (redux-saga) does. This makes testing really easy because we don't perform any I/O.
import { call } from 'redux-saga/effects'
import Api from '...'
const iterator = loadUserById(1)
assert.deepEqual(
iterator.next().value,
{
type: USER__LOAD_STARTED,
payload: { id: 1 }
});
assert.deepEqual(
iterator.next().value,
call(Api.fetchUser, 1),
"fetchUser should yield an Effect call(Api.fetchUser, 1)"
)
assert.deepEqual(
iterator.next().value,
{
type: USER__LOAD_FINISHED,
payload: { id: 1 }
});
Look ma! No mocks! Endpoints are tested via integration tests. Components are tested as components. Reducers as reducers. Action Creators as action creators. All without mocks.
- Simple error handling using try/catch.
- There are many optimizations the effect manager can take such as running tasks in parallel.
- Greedily taking future side-effects, performing the I/O but not applying the effect until the code needs them. (Like prefetch but for all your state too.)
- Side effect cancellation midflight.
- A log of all stateful effects enables replication (to a localstorage for offline mode for example, service workers, QA reproducing issues), caching (memory) and time travel (developer tooling).
- Racing effects... e.g. Dispatch similar from two endpoints and let winner apply.
Hmm. Actually, reading this in more detail: am I correct that this isn't so much a proposal for a brand new or hypothetical way of doing things, as it is a description of how moving more logic into sagas/observables enables decoupling things?