If debugging is the process of removing software bugs, then programming must be the process of putting them in.
- Edsger Dijkstra
You've probably heard that the cost of software defects increases the further-along it takes to find them. A defect introduced and resolved during the design phase is relatively cheap. On the other hand, a defect introduced during the requirements-gathering process and not discovered until the implementation phase can be extremely expensive to resolve.
Here are some strategies you can use on a large Redux project to help prevent bugs -- defects in the development phase.
1. Automated Testing
It should go without saying that a large codebase -- of any kind -- should have some form of automated testing.
Writing automated tests has at least three important benefits.
- It forces you to think through your solution in a different mindset.
- It ensures the code does what it is supposed to do.
- It ensures that, over time as features are added and refactored, no existing code is broken.
That last point is especially important because the code is fresh in your mind when you write it... but is it still fresh six months later? Two years later? It certainly won't be fresh for that new developer that just started and has to add a new feature in your code.
Prevents: all kinds of bugs; most notably it helps prevent regression bugs.
2. Static Type Checking
Redux by nature is very simple. But that is not to say that applications built with Redux are simple. Complexity comes from the many little pieces assembled together.
And let's be honest... are you really going to write a unit test for this:
export const loginRequest = (username, password) => ({ type: 'LOGIN_REQUEST', payload: { username, password, },});
Hey, weren't you just saying how important unit testing is?
Ha! I said "automated testing", which encompasses more than just unit testing. Sure, you could write unit tests for simple action creators if that helps you sleep at night. I won't hold it against you.
By the way... how many Redux actions do you have in your project? 50? 100? 500? And that's just actions! There are still reducers, thunks/sagas, and components to think about. That's a lot of work. Heck, half of the community already complains about too much boilerplate code in Redux.
And what about those times you wrote some JavaScript code, switched over to your app, performed a few operations to get to where your functionality is implemented, and tested your new code... only to see it break and then realize you forgot to change that other thing over there. Then you switch back to your IDE to fix the things you forgot (and pray you didn't forget anything else).
The context switching alone is a huge productivity killer! How much time do you waste like this over the course of an entire project?
What if your tools automatically took care of making sure that the hundreds (thousands?) of little pieces fit together correctly? An entire class of runtime bugs eliminated. Easily, permanently. Gone... just like that. Imagine being able to actually focus on your code and stay in 'The Zone' longer?
TypeScript (and Flow) deliver on these promises via static type checking.
When you -- or that new developer -- starts adding / changing / removing code, the TypeScript compiler informs you when something related breaks. And this is built right into the IDE (e.g. VSCode) so you get notified immediately.
Prevents: an entire class of bugs; also aids in code comprehension
Interested to try it? Read Starting a React/Redux Project with TypeScript
3. Enforce Immutability
One of the three principles of Redux is that State is Read only.
Reducers are not supposed to change state. Instead, they are supposed to return a new state. Similarly, you are not supposed to modify state directly. You are supposed to send an action to the store, which then delegates to a reducer.
Well, that's a lot of supposed to's and not supposed to's... none of which are actually enforced by Redux.
Let's build a really, really powerful state management framework but leave a critical weakness at the core. What could possibly go wrong?
Here are some ideas to help you fill the gap:
TypeScript type annotations
Technically, TypeScript could be used to enforce immutability by declaring every single piece of state as readonly
. This could be cumbersome if there is a lot of state in Redux. But it may be a pratical solution for your project.
Immutable.js
You could use Immutable.js to wrap all your objects in immutable containers. I did this on one project and didn't like it.
- Can introduce serious performance problems with Redux if you use it incorrectly (.toJS() calls at the wrong time)
- Because of #1, the immutable objects leak into every aspect of the project
- Loses type information for some calls (e.g. .getIn(['foo', 'bar']))
- It's a new syntax to learn
- Spread operator can replace most of the functionality
- Some overhead added to the bundle (albeit a small amount ~20KB)
Store Enhancer
You could write a store enhancer that uses deepfreezer to ensure that state leaving the reducer is immutable. Apply it to the store in development builds as a safety net and exclude it from production builds to avoid the overhead.
Prevents: bugs where UI doesn't update
4. Parameter Validation in Action Creators
Debugging will be easier for you if you catch bugs as close to the source as possible.
Adding TypeScript to your project will go a long way to ensuring that bad data doesn't get into your store. But TypeScript will not catch everything if your code makes liberal use of any
data types and casting between types.
For instance, in one project I noticed a bug where an API was being called like this:
GET /api/thing/undefined
The call was coming from within some nested sagas. The outermost one had extracted the ID from an action payload. Have you ever seen the callstack from nested sagas? It's not terribly useful.
This bug would have been caught earlier had I put validation in my action creator:
export const getThing = (id: string) => { if (!id) { throw new Error('ID is missing'); } return { type: 'GET_THING', payload: id, };};
Most of the time TypeScript is going to catch these kinds of errors for you. Sometimes it won't -- like if you're doing things in Redux-Saga.
If you can't or won't use static type checking then validating parameters in action creators might be for you.
Prevents: bugs from bad data from getting into your store
5. Peer Code Reviews
Like automated testing, peer code reviews as a practice isn't specific to Redux. Every professional development shop should be doing these.
The idea is that humans make mistakes and we software developers are human (mostly, anyways. #amirite?).
Having someone else review your code is an extra line of defense. You've been toiling away in the code. You think about it a certain way. A fresh pair of eyes on your code may see things you can't.
Prevents: All kinds of bugs (but is not foolproof... see: humans make mistakes -- your peers are human too, right? :)
Go Prevent Some Bugs!
You can save yourself time and angst, and produce a higher-quality product by implementing automated tests, writing code with TypeScript, enforcing immutability, validating action creator inputs, and having your work reviewed by peers.
Here are some more fun programming quotes for you.
Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live.
- Martin Golding
Without requirements or design, programming is the art of adding bugs to an empty text file.
- Louis Srygley
One man’s crappy software is another man’s full time job.
- Jessica Gaston