Javascript Power Tools Part III: Real-world redux-saga Patterns
In the past two articles, we've talked a lot about redux-saga
in the abstract, without much concern for real-world applications. Now that we’re equipped with new knowledge, we're ready to jump in and start putting the pieces back together.
First, we'll take a look at a pattern for structuring behavior in single-page
applications using redux-saga
and redux-little-router
, and then we'll build
a saga that implements the business logic for a basic form.
Pairing redux-saga
with redux-little-router
I really love redux-little-router
, and I'm not just saying that because it's a
Formidable project. When you're already reading from the Redux store to access
state and dispatching Redux actions to modify state, it feels very elegant to
interact with the browser location the same way. However, when used in
conjunction with redux-saga
, we gain an additional benefit: the ability to
trigger behaviors in response to browser location changes. Why is this beneficial?
Well, first off, it separates the business logic associated with route changes from the view lifecycle. When I'm building a React application, I typically prefer to keep my React components as stateless and declarative as possible, and shoehorning business logic into React components makes that difficult at best.
Secondly, in a large project, it typically behooves us to avoid running too many
concurrent sagas at once because it can quickly become difficult to determine
the ramifications of dispatching a particular action. If we're monitoring for
route changes from within redux-saga
, we can ensure that the only sagas
running at any given time are those relevant to the current route.
With this approach, each route becomes a 'mini-application', which starts up when the user navigates to it and shuts down when they navigate away. Let's take a look at how we could implement this.
First, we'll start out with a rootSaga
which will be the entry point for our
entire application.
export default function* rootSaga() { console.log("Starting up the root saga!"); }
Next, we'll put together a configureStore
function which attaches both
redux-little-router
and redux-saga
to our Redux store.
const REDUCERS = { todos: (state = {}, action) => state, debug: (state, action) => action, }; const ROUTES = { '/' : {}, '/todos' : {}, '/todos/new' : {}, '/todos/:id' : {}, '/todos/:id/edit' : {}, }; export const configureStore = (initialState = {}) => { const { reducer : routerReducer, enhancer : routerEnhancer, middleware : routerMiddleware, } = routerForBrowser({ routes: ROUTES }); const sagaMiddleware = createSagaMiddleware(); const store = createStore( combineReducers({ ...REDUCERS, router: routerReducer, }), initialState, compose( routerEnhancer, applyMiddleware( sagaMiddleware, routerMiddleware, ), ) ); sagaMiddleware.run(rootSaga); const initialRouterState = store.getState().router; store.dispatch(initializeCurrentLocation(initialRouterState)); return store; };
The first thing to notice here is the initializeCurrentLocation
action we're
dispatching during initialization. Since we've already started our rootSaga
,
we can take
that action right away.
export default function* rootSaga() { console.log("Starting up the root saga!"); const action = yield take("ROUTER_LOCATION_CHANGED"); const location = action.payload; console.log("Your current location is:", location); }
Let's go a little further, and make our saga take
any
"ROUTER_LOCATION_CHANGED" action.
export default function* rootSaga() { console.log("Starting up the root saga!"); while (true) { const action = yield take('ROUTER_LOCATION_CHANGED'); const location = action.payload; console.log("Your current location is:", location); } }
To help us make our code more functional, redux-saga provides a helper called
takeEvery
which does something somewhat similar.
export default function* rootSaga() { console.log("Starting up the root saga!"); yield takeEvery('ROUTER_LOCATION_CHANGED', function* (action) { const location = action.payload; console.log("Your current location is:", location); }); }
There's a problem here, though. Suppose the inner saga was performing some
long-running action (emulated by a 10-second delay
in the example below). What
would happen if the user started navigating around the site quickly?
export default function* rootSaga() { console.log("Starting up the root saga!"); yield takeEvery('ROUTER_LOCATION_CHANGED', function* (action) { const location = action.payload; yield delay(10000); Wait 10 seconds, for some reason console.log("Your current location is:", location); }); }
You guessed it. This is Race-condition Central, population: you. Fortunately,
redux-saga provides a helper called takeLatest
, which ensures that only one
saga is running at a time by cancelling the previously running saga when a new
action comes in. Let's make the change, and add some exception handling so we
can see this behavior in vivo.
export default function* rootSaga() { console.log("Starting up the root saga!"); yield takeLatest('ROUTER_LOCATION_CHANGED', function* (action) { const location = action.payload; try { yield delay(10000); console.log("Your current location is:", location); } finally { if (yield cancelled()) { console.log("Fine, fine! Your location WAS", location); } } }); }
So, we've now built a saga with the following properties:
- Starts a new saga whenever a location change occurs
- Cancels the saga associated with the previous location change
- Uses the current location to perform effects
From here, we'll take advantage of the third bullet point to branch into different sagas based on the current route. First, let's extract that inline saga into the module scope and give it a name.
function* navigationSaga(action) { const location = action.payload; console.log("Your current location is:", location); } export default function* rootSaga() { console.log("Starting up the root saga!"); yield takeLatest('ROUTER_LOCATION_CHANGED', navigationSaga); }
Now, let's create some stubs for the different behavior we want to perform at each route.
function* navigationSaga(action) { const location = action.payload; switch (location.route) { case '/': { break; } case '/todos': { break; } case '/todos/:id': { break; } case '/todos/new': { break; } case '/todos/:id/edit': { break; } default: { break; } } } export default function* rootSaga() { console.log("Starting up the root saga!"); yield takeLatest('ROUTER_LOCATION_CHANGED', navigationSaga); }
This seems like it could get really messy before long. And what if someone forgets a break statement? Let's keep refactoring.
const SAGA_FOR_ROUTE = { '/' : function* homeSaga() {}, '/todos' : function* listTodosSaga() {}, '/todos/:id' : function* showTodoSaga() {}, '/todos/new' : function* newTodoSaga() {}, '/todos/:id/edit' : function* editTodoSaga() {}, }; function* navigationSaga(action) { const location = action.payload; const saga = SAGA_FOR_ROUTE[location.route]; if (saga) { yield call(saga, location); } } export default function* rootSaga() { yield [ takeLatest("ROUTER_LOCATION_CHANGED", navigationSaga), ]; }
Wonderful! In addition to being less verbose, redux-saga
can log our sagas by
name if they are cancelled due to a ROUTER_LOCATION_CHANGED (in development
mode, anyways).
Now, each route-specific saga can react to being started and stopped completely
on its own. For instance, if we were to implement editTodoSaga
, it would
perhaps look something like this.
function* editTodoSaga(location) { const { id } = location.params; try { // Some behaviors which happen once when the user navigates to this route yield put(startedEditingTodo(id)); yield call(ensureTodoExistsLocally, id); // Start up some long-running behaviors tied to this saga's lifetime. yield all([ fork(takeEvery, "BLAH", handleBlahSaga), fork(someOtherLongRunningSaga), ]); } finally { // Whether we finished naturally or got cancelled by our parent because the // route changed, clean up after ourselves before exiting for good. yield put(finishedEditingTodo(id)); } }
'Create Todo' form
Along those lines, let's take a look at a "mini-application" we could
implement. Specifically, we'll write an implementation for the newTodoSaga
associated with the /todos/new
route above.
We'll assume that somewhere in the view layer there's a form the user's filling out and eventually submitting - we want to implement just the business logic for it. Let's write a quick outline of that.
- Wait for the user to submit the form
- Perform client-side validation
- If client-side validation fails, show an error message and start over
- Send the form data to the server
- If successful, show the user their new to-do
- If not successful, show an error message and start over
Sounds easy enough. Let's start out with an empty saga.
function* newTodoSaga() { }
First things first: we need to wait for the user to submit a form. We'll assume
that the view layer will dispatch an action called SUBMIT_TODO_FORM
with the
form data as payload.
function* newTodoSaga() { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; }
Next, let's write a quick validation function...
const validateForm = (formData) => { if (formData.name === '') { throw new Error('"name" field must not be blank!'); } }
...and invoke it like so.
function* newTodoSaga() { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; yield call(validateForm, formData); }
At this point, the user can submit their form for validation, but there are two problems: if their input fails validation, we'd like to tell them why, and naturally, we'd like to allow them to resubmit the form.
Let's handle these issues separately.
First, we need to allow the user to retry form submission. The easiest way to do this is with a loop which will not exit until their input passes validation.
function* newTodoSaga() { while (true) { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; try { yield call(validateForm, formData); break; } catch (err) { continue; } } }
Note that the continue
statement is not strictly necessary, but I consider it
good form in this circumstance. It explicitly indicates the expected flow of
this procedure, rather than expecting the reader to notice that the loop will start
over. We'll see why this is important in a moment.
We've handled both the valid and invalid cases, so now let's assume that we
have a showErrorNotification
action creator which dispatches some action
indicating that the application should show an error notification.
function* newTodoSaga() { while (true) { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; try { yield call(validateForm, formData); break; } catch (err) { yield put(showErrorNotification(err.message); continue; } } }
By the time we reach the break
statement, we know we have valid form
data, so let's send it to an API endpoint. We are going to make an
assumption that we have a function called createTodo
which somehow does
this for us. (I've had success with these sorts of thin layers over the Fetch
API.)
Because this API call could fail for all kinds of reasons, we need to make sure
we handle those cases by using a simple try-catch block, just like we did
before. (Notice that we've removed the break
statement.)
function* newTodoSaga() { while (true) { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; try { yield call(validateForm, formData); } catch (err) { yield put(showErrorNotification(err.message); continue; } try { yield call(createTodoApi, formData); break; } catch (err) { yield put(showErrorNotification(err.message); continue; } } }
Notice that our earlier continue
statement has now become crucial to the
correct behavior. If we hadn't been so conscientious, this would have caused a
bug and probably made us feel kinda dumb for missing it.
And yes, I can hear you saying "but this is PROCEDURAL!" to which I say, "business logic is inherently procedural, and expressing it as such makes our intent clearer." That being said, it is easy to let code like this get out of hand, so it’s important to stay disciplined and keep it focused.
Business logic code should tell a story. Like a good storyteller, it should draw focus to the important details and elide over the minute ones. That is, unless we ask for them.
Looking at this code, we see the story:
- Wait for a
SUBMIT_TODO_FORM
action - Call
validateForm
with action.payload.formData- If an error occurs, notify the user and start over
- Otherwise, proceed to the next step
- POST the form data to the server
- If an error occurs, notify the user and start over
Looks like we've stuck to the business logic pretty well so far. Now then, the
only thing left is to show the user their new to-do. Since we're using
redux-little-router
, we'll dispatch a Redux action which routes them to the
"show todo" page.
function* newTodoSaga() { while (true) { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; try { yield call(validateForm, formData); } catch (err) { yield put(showErrorNotification(err.message); continue; } try { const todo = yield call(createTodoApi, formData); yield routerPush(`/todos/${todo.id}`); break; } catch (err) { yield put(showErrorNotification(err.message); continue; } } }
This is good enough, but let's take a short aside to talk about saga structure and separation of responsibilities. This saga's primary responsibility is validating and submitting form data to the server. We know we want to redirect the user's browser after all that happens, but with the way things are currently structured, the sequence of events isn't all that obvious. At a high level, we're doing this:
- When the user successfully creates a new todo,
- Show the user the newly created todo
Let's separate these two concerns completely.
function* awaitSuccessfulTodoCreation() { while (true) { const action = yield take('SUBMIT_TODO_FORM'); const formData = action.payload; try { yield call(validateForm, formData); } catch (err) { yield put(showErrorNotification(err.message); continue; } try { const todo = yield call(createTodoApi, formData); return todo; } catch (err) { yield put(showErrorNotification(err.message); continue; } } } function* newTodoSaga() { const todo = yield call(awaitSuccessfulTodoCreation); yield put(showSuccessNotification("Congratulations! You created a new todo.")); yield routerPush(`/todos/${todo.id}`); }
Now there's no question as to whether or not todo
has been created. We
observe that the only 'escape route' from awaitSuccessfulTodoCreation
is the
return
statement, which can only happen after a successful todo creation. So
if control returns to newTodoSaga
, we're certain that it's time to show a
success message and send the user on their way.
However, also note what happens if the user navigates away from /todos/new
during this process: redux-saga
will cancel newTodoSaga
. Moreover, if
newTodoSaga
is still blocking on awaitSuccessfulTodoCreation
, the
cancellation will propagate "downward" to that as well, cleanly exiting the
whole shebang without causing any more effects.
Either way, we now have a saga that is responsible for doing one thing only. We can think about this part of the application in isolation, and that goes a long way towards wrangling in the complexity of larger single-page-applications.
Conclusion
This about wraps up our series on redux-saga
. I hope you've learned a lot and
that you're able to make use of some of these ideas in your own projects!
Questions, comments, and suggestions are welcome - I'm available on Twitter at
@mhink1103. Let me know what you'd like to see
next in the Javascript Power Tools series.
(Also, thanks to all the rad folks at Formidable for their contributions and suggestions- especially Becca Lee, without whom this would all still be a jumbled mess of .md files on my laptop.)