Javascript Power Tools Part III: Real-world redux-saga PatternsJavascriptPowerToolsPartIII:Real-worldredux-sagaPatternsJavascriptPowerToolsPartIII:Real-worldredux-sagaPatterns
Matt Hink
7 Jun 2017
Share
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.
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");}
js
Next, we'll put together a configureStore function which attaches both
redux-little-router and redux-saga to our Redux 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.
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");const action =yieldtake("ROUTER_LOCATION_CHANGED");constlocation= action.payload;console.log("Your current location is:",location);}
js
Let's go a little further, and make our saga takeany
"ROUTER_LOCATION_CHANGED" action.
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");while(true){const action =yieldtake('ROUTER_LOCATION_CHANGED');constlocation= action.payload;console.log("Your current location is:",location);}}
js
To help us make our code more functional, redux-saga provides a helper called
takeEvery which does something somewhat similar.
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");yieldtakeEvery('ROUTER_LOCATION_CHANGED',function*(action){constlocation= action.payload;console.log("Your current location is:",location);});}
js
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?
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");yieldtakeEvery('ROUTER_LOCATION_CHANGED',function*(action){constlocation= action.payload;yielddelay(10000);Wait10 seconds,for some reason
console.log("Your current location is:",location);});}
js
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.
exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");yieldtakeLatest('ROUTER_LOCATION_CHANGED',function*(action){constlocation= action.payload;try{yielddelay(10000);console.log("Your current location is:",location);}finally{if(yieldcancelled()){console.log("Fine, fine! Your location WAS",location);}}});}
js
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){constlocation= action.payload;console.log("Your current location is:",location);}exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");yieldtakeLatest('ROUTER_LOCATION_CHANGED', navigationSaga);}
js
Now, let's create some stubs for the different behavior we want to perform
at each route.
function*navigationSaga(action){constlocation= 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;}}}exportdefaultfunction*rootSaga(){console.log("Starting up the root saga!");yieldtakeLatest('ROUTER_LOCATION_CHANGED', navigationSaga);}
js
This seems like it could get really messy before long. And what if someone
forgets a break statement? Let's keep refactoring.
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 routeyieldput(startedEditingTodo(id));yieldcall(ensureTodoExistsLocally, id);// Start up some long-running behaviors tied to this saga's lifetime.yieldall([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.yieldput(finishedEditingTodo(id));}}
js
'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(){}
js
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.
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.
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.
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.)
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 =yieldtake('SUBMIT_TODO_FORM');const formData = action.payload;try{yieldcall(validateForm, formData);}catch(err){yieldput(showErrorNotification(err.message);continue;}try{const todo =yieldcall(createTodoApi, formData);yieldrouterPush(`/todos/${todo.id}`);break;}catch(err){yieldput(showErrorNotification(err.message);continue;}}}
js
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 =yieldtake('SUBMIT_TODO_FORM');const formData = action.payload;try{yieldcall(validateForm, formData);}catch(err){yieldput(showErrorNotification(err.message);continue;}try{const todo =yieldcall(createTodoApi, formData);return todo;}catch(err){yieldput(showErrorNotification(err.message);continue;}}}function*newTodoSaga(){const todo =yieldcall(awaitSuccessfulTodoCreation);yieldput(showSuccessNotification("Congratulations! You created a new todo."));yieldrouterPush(`/todos/${todo.id}`);}
js
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.)