A Deep Dive into Redux
Building stateful modern applications is complex. As state mutates, the app becomes unpredictable and hard to maintain. That’s where Redux comes in. Redux is a lightweight library that tackles state. Think of it as a state machine.
In this article, I’ll delve into Redux’s state container by building a payroll processing engine. The app will store pay stubs, along with all the extras — such as bonuses and stock options. I’ll keep the solution in plain JavaScript with TypeScript for type checking. Since Redux is super testable, I’ll also use Jest to verify the app.
For the purposes of this tutorial, I’ll assume a moderate level of familiarity with JavaScript, Node, and npm.
To begin, you can initialize this app with npm:
npm init
When asked about the test command, go ahead and put jest
. This means npm t
will fire up Jest and run all unit tests. The main file will be index.js
to keep it nice and simple. Feel free to answer the rest of the npm init
questions to your heart’s content.
I’ll use TypeScript for type checking and nailing down the data model. This aids in conceptualizing what we’re trying to build.
To get going with TypeScript:
npm i typescript --save-dev
I’ll keep dependencies that are part of the dev workflow in devDependencies
. This makes it clear which dependencies are for developers and which goes to prod. With TypeScript ready, add a start
script in the package.json
:
"start": "tsc && node .bin/index.js"
Create an index.ts
file under the src
folder. This separates source files from the rest of the project. If you do an npm start
, the solution will fail to execute. This is because you’ll need to configure TypeScript.
Create a tsconfig.json
file with the following configuration:
{
"compilerOptions": {
"strict": true,
"lib": ["esnext", "dom"],
"outDir": ".bin",
"sourceMap": true
},
"files": [
"src/index"
]
}
I could have put this configuration in a tsc
command-line argument. For example, tsc src/index.ts --strict ...
. But it’s much cleaner to go ahead and put all this in a separate file. Note the start
script in package.json
only needs a single tsc
command.
Here are sensible compiler options that will give us a good starting point, and what each option means:
- strict: enable all strict type checking options, i.e.,
--noImplicitAny
,--strictNullChecks
, etc. - lib: list of library files included in the compilation
- outDir: redirect output to this directory
- sourceMap: generate source map file useful for debugging
- files: input files fed to the compiler
Because I’ll be using Jest for unit testing, I’ll go ahead and add it:
npm i jest ts-jest @types/jest @types/node --save-dev
The ts-jest
dependency adds type checking to the testing framework. One gotcha is to add a jest
configuration in package.json
:
"jest": {
"preset": "ts-jest"
}
This makes it so the testing framework picks up TypeScript files and knows how to transpile them. One nice feature with this is you get type checking while running unit tests. To make sure this project is ready, create a __tests__
folder with an index.test.ts
file in it. Then, do a sanity check. For example:
it('is true', () => {
expect(true).toBe(true);
});
Doing npm start
and npm t
now runs without any errors. This tells us we’re now ready to start building the solution. But before we do, let’s add Redux to the project:
npm i redux --save
This dependency goes to prod. So, no need to include it with --save-dev
. If you inspect your package.json
, it goes in dependencies
.
Payroll Engine in Action
The payroll engine will have the following: pay, reimbursement, bonus, and stock options. In Redux, you can’t directly update state. Instead, actions are dispatched to notify the store of any new changes.
So, this leaves us with the following action types:
const BASE_PAY = 'BASE_PAY';
const REIMBURSEMENT = 'REIMBURSEMENT';
const BONUS = 'BONUS';
const STOCK_OPTIONS = 'STOCK_OPTIONS';
const PAY_DAY = 'PAY_DAY';
The PAY_DAY
action type is useful for dolling out a check on pay day and keeping track of pay history. These action types guide the rest of the design as we flesh out the payroll engine. They capture events in the state lifecycle — for example, setting a base pay amount. These action events can attach to anything, whether that be a click event or a data update. Redux action types are abstract to the point where it doesn’t matter where the dispatch comes from. The state container can run both on the client and/or server.
TypeScript
Using type theory, I’ll nail down the data model in terms of state data. For each payroll action, say an action type and an optional amount. The amount is optional, because PAY_DAY
doesn’t need money to process a paycheck. I mean, it could charge customers but leave it out for now (maybe introducing it in version two).
So, for example, put this in src/index.ts
:
interface PayrollAction {
type: string;
amount?: number;
}
For pay stub state, we need a property for base pay, bonus, and whatnot. We’ll use this state to maintain a pay history as well.
This TypeScript interface ought to do it:
interface PayStubState {
basePay: number;
reimbursement: number;
bonus: number;
stockOptions: number;
totalPay: number;
payHistory: Array<PayHistoryState>;
}
The PayStubState
is a complex type, meaning it depends on another type contract. So, define the payHistory
array:
interface PayHistoryState {
totalPay: number;
totalCompensation: number;
}
With each property, note TypeScript specifies the type using a colon. For example, : number
. This settles the type contract and adds predictability to the type checker. Having a type system with explicit type declarations enhances Redux. This is because the Redux state container is built for predictable behavior.
This idea isn’t crazy or radical. Here’s a good explanation of it in Learning Redux, Chapter 1 (SitePoint Premium members only).
As the app mutates, type checking adds an extra layer of predictability. Type theory also aids as the app scales because it’s easier to refactor large sections of code.
Conceptualizing the engine with types now helps to create the following action functions:
export const processBasePay = (amount: number): PayrollAction =>
({type: BASE_PAY, amount});
export const processReimbursement = (amount: number): PayrollAction =>
({type: REIMBURSEMENT, amount});
export const processBonus = (amount: number): PayrollAction =>
({type: BONUS, amount});
export const processStockOptions = (amount: number): PayrollAction =>
({type: STOCK_OPTIONS, amount});
export const processPayDay = (): PayrollAction =>
({type: PAY_DAY});
What’s nice is that, if you attempt to do processBasePay('abc')
, the type checker barks at you. Breaking a type contract adds unpredictability to the state container. I’m using a single action contract like PayrollAction
to make the payroll processor more predictable. Note amount
is set in the action object via an ES6 property shorthand. The more traditional approach is amount: amount
, which is long-winded. An arrow function, like () => ({})
, is one succinct way to write functions that return an object literal.
Reducer as a Pure Function
The reducer functions need a state
and an action
parameter. The state
should have an initial state with a default value. So, can you imagine what our initial state might look like? I’m thinking it needs to start at zero with an empty pay history list.
For example:
const initialState: PayStubState = {
basePay: 0, reimbursement: 0,
bonus: 0, stockOptions: 0,
totalPay: 0, payHistory: []
};
The type checker makes sure these are proper values that belong in this object. With the initial state in place, begin creating the reducer function:
export const payrollEngineReducer = (
state: PayStubState = initialState,
action: PayrollAction): PayStubState => {
The Redux reducer has a pattern where all action types get handled by a switch
statement. But before going through all switch cases, I’ll create a reusable local variable:
let totalPay: number = 0;
Note that it’s okay to mutate local variables if you don’t mutate global state. I use a let
operator to communicate this variable is going to change in the future. Mutating global state, like the state
or action
parameter, causes the reducer to be impure. This functional paradigm is critical because reducer functions must remain pure. If you’re struggling with this paradigm, check out this explanation from JavaScript Novice to Ninja, Chapter 11 (SitePoint Premium members only).
Start the reducer’s switch statement to handle the first use case:
switch (action.type) {
case BASE_PAY:
const {amount: basePay = 0} = action;
totalPay = computeTotalPay({...state, basePay});
return {...state, basePay, totalPay};
I’m using an ES6 rest
operator to keep state properties the same. For example, ...state
. You can override any properties after the rest operator in the new object. The basePay
comes from destructuring, which is a lot like pattern matching in other languages. The computeTotalPay
function is set as follows:
const computeTotalPay = (payStub: PayStubState) =>
payStub.basePay + payStub.reimbursement
+ payStub.bonus - payStub.stockOptions;
Note you deduct stockOptions
because the money will go towards buying company stock. Say you want to process a reimbursement:
case REIMBURSEMENT:
const {amount: reimbursement = 0} = action;
totalPay = computeTotalPay({...state, reimbursement});
return {...state, reimbursement, totalPay};
Since amount
is optional, make sure it has a default value to reduce mishaps. This is where TypeScript shines, because the type checker picks up on this pitfall and barks at you. The type system knows certain facts so it can make sound assumptions. Say you want to process bonuses:
case BONUS:
const {amount: bonus = 0} = action;
totalPay = computeTotalPay({...state, bonus});
return {...state, bonus, totalPay};
This pattern makes the reducer readable because all it does is maintain state. You grab the action’s amount, compute total pay, and create a new object literal. Processing stock options is not much different:
case STOCK_OPTIONS:
const {amount: stockOptions = 0} = action;
totalPay = computeTotalPay({...state, stockOptions});
return {...state, stockOptions, totalPay};
For processing a paycheck on pay day, it’ll need to blot out bonus and reimbursement. These two properties don’t remain in state per paycheck. And, add an entry to pay history. Base pay and stock options can stay in state because they don’t change as often per paycheck. With this in mind, this is how PAY_DAY
goes:
case PAY_DAY:
const {payHistory} = state;
totalPay = state.totalPay;
const lastPayHistory = payHistory.slice(-1).pop();
const lastTotalCompensation = (lastPayHistory
&& lastPayHistory.totalCompensation) || 0;
const totalCompensation = totalPay + lastTotalCompensation;
const newTotalPay = computeTotalPay({...state,
reimbursement: 0, bonus: 0});
const newPayHistory = [...payHistory, {totalPay, totalCompensation}];
return {...state, reimbursement: 0, bonus: 0,
totalPay: newTotalPay, payHistory: newPayHistory};
In an array like newPayHistory
, use a spread
operator, which is the reverse of rest
. Unlike rest, which collects properties in an object, this spreads items out. So, for example, [...payHistory]
. Even though both these operators look similar, they aren’t the same. Look closely, because this might come up in an interview question.
Using pop()
on payHistory
doesn’t mutate state. Why? Because slice()
returns a brand new array. Arrays in JavaScript are copied by reference. Assigning an array to a new variable doesn’t change the underlying object. So, one must be careful when dealing with these types of objects.
Because there’s a chance lastPayHistory
is undefined, I use poor man’s null coalescing to initialize it to zero. Note the (o && o.property) || 0
pattern to coalesce. Maybe a future version of JavaScript or even TypeScript will have a more elegant way of doing this.
Every Redux reducer must define a default
branch. To make sure state doesn’t become undefined
:
default:
return state;
Testing the Reducer Function
One of the many benefits of writing pure functions is that they’re testable. A unit test is one where you must expect predictable behavior — to the point where you can automate all tests as part of a build. In __tests__/index.test.ts
, knock out the dummy test and import all functions of interest:
import { processBasePay,
processReimbursement,
processBonus,
processStockOptions,
processPayDay,
payrollEngineReducer } from '../src/index';
Note that all functions were set with an export
so you can import them in. For a base pay, fire up the payroll engine reducer and test it:
it('process base pay', () => {
const action = processBasePay(10);
const result = payrollEngineReducer(undefined, action);
expect(result.basePay).toBe(10);
expect(result.totalPay).toBe(10);
});
Redux sets the initial state as undefined
. Therefore, it’s always a good idea to provide a default value in the reducer function. What about processing a reimbursement?
it('process reimbursement', () => {
const action = processReimbursement(10);
const result = payrollEngineReducer(undefined, action);
expect(result.reimbursement).toBe(10);
expect(result.totalPay).toBe(10);
});
The pattern here is the same for processing bonuses:
it('process bonus', () => {
const action = processBonus(10);
const result = payrollEngineReducer(undefined, action);
expect(result.bonus).toBe(10);
expect(result.totalPay).toBe(10);
});
For stock options:
it('skip stock options', () => {
const action = processStockOptions(10);
const result = payrollEngineReducer(undefined, action);
expect(result.stockOptions).toBe(0);
expect(result.totalPay).toBe(0);
});
Note totalPay
must remain the same when stockOptions
is greater than totalPay
. Since this hypothetical company is ethical, is doesn’t want to take money from its employees. If you run this test, note that totalPay
is set to -10
because stockOptions
gets deducted. This is why we test code! Let’s fix this where it computes total pay:
const computeTotalPay = (payStub: PayStubState) =>
payStub.totalPay >= payStub.stockOptions
? payStub.basePay + payStub.reimbursement
+ payStub.bonus - payStub.stockOptions
: payStub.totalPay;
If the employee doesn’t make enough money to buy company stock, go ahead and skip the deduction. Also, make sure it resets stockOptions
to zero:
case STOCK_OPTIONS:
const {amount: stockOptions = 0} = action;
totalPay = computeTotalPay({...state, stockOptions});
const newStockOptions = totalPay >= stockOptions
? stockOptions : 0;
return {...state, stockOptions: newStockOptions, totalPay};
The fix figures out whether they have enough in newStockOptions
. With this, unit tests pass, and the code is sound and makes sense. We can test the positive use case where there’s enough money for a deduction:
it('process stock options', () => {
const oldAction = processBasePay(10);
const oldState = payrollEngineReducer(undefined, oldAction);
const action = processStockOptions(4);
const result = payrollEngineReducer(oldState, action);
expect(result.stockOptions).toBe(4);
expect(result.totalPay).toBe(6);
});
For pay day, test with multiple states and make sure one-time transactions don’t persist:
it('process pay day', () => {
const oldAction = processBasePay(10);
const oldState = payrollEngineReducer(undefined, oldAction);
const action = processPayDay();
const result = payrollEngineReducer({...oldState, bonus: 10,
reimbursement: 10}, action);
expect(result.totalPay).toBe(10);
expect(result.bonus).toBe(0);
expect(result.reimbursement).toBe(0);
expect(result.payHistory[0]).toBeDefined();
expect(result.payHistory[0].totalCompensation).toBe(10);
expect(result.payHistory[0].totalPay).toBe(10);
});
Note how I tweak oldState
to verify bonus
and reset reimbursement
back to zero.
What about the default branch in the reducer?
it('handles default branch', () => {
const action = {type: 'INIT_ACTION'};
const result = payrollEngineReducer(undefined, action);
expect(result).toBeDefined();
});
Redux sets an action type like INIT_ACTION
in the beginning. All we care about is that our reducer sets some initial state.
Putting It All Together
At this point, you may start to wonder if Redux is more of a design pattern than anything else. If you answer that it’s both a pattern and a lightweight library, then you’re correct. In index.ts
, import Redux:
import { createStore } from 'redux';
The next code sample can go wrapped around this if
statement. This is a stopgap, so unit tests don’t leak into integration tests:
if (!process.env.JEST_WORKER_ID) {
}
I don’t recommend doing this in a real project. Modules can go in separate files to isolate components. This makes it more readable and won’t leak concerns. Unit tests also benefit from the fact modules run in isolation.
Fire up a Redux store with the payrollEngineReducer
:
const store = createStore(payrollEngineReducer, initialState);
const unsubscribe = store.subscribe(() => console.log(store.getState()));
Every store.subscribe()
returns a subsequent unsubscribe()
function useful for cleaning up. It unsubscribes callbacks when actions get dispatched through the store. Here, I’m outputting current state to the console with store.getState()
.
Say this employee makes 300
, has a 50
reimbursement, 100
bonus, and 15
going towards company stock:
store.dispatch(processBasePay(300));
store.dispatch(processReimbursement(50));
store.dispatch(processBonus(100));
store.dispatch(processStockOptions(15));
store.dispatch(processPayDay());
To make it more interesting, do another 50
reimbursement and process another paycheck:
store.dispatch(processReimbursement(50));
store.dispatch(processPayDay());
Finally, run yet another paycheck and unsubscribe from the Redux store:
store.dispatch(processPayDay());
unsubscribe();
The end result looks like this:
{ "basePay": 300,
"reimbursement": 0,
"bonus": 0,
"stockOptions": 15,
"totalPay": 285,
"payHistory":
[ { "totalPay": 435, "totalCompensation": 435 },
{ "totalPay": 335, "totalCompensation": 770 },
{ "totalPay": 285, "totalCompensation": 1055 } ] }
As shown, Redux maintains state, mutates, and notifies subscribers in one sweet little package. Think of Redux as a state machine that is the source of truth for state data. All this, while embracing the best coding has to offer, such as a sound functional paradigm.
Conclusion
Redux has a simple solution to the complex problem of state management. It rests on a functional paradigm to reduce unpredictability. Because reducers are pure functions, it’s super easy to unit test. I decided to use Jest, but any test framework that supports basic assertions will work too.
TypeScript adds an extra layer of protection with type theory. Couple type checking with functional programming and you get sound code that hardly breaks. Best of all, TypeScript stays out of the way while adding value. If you notice, there’s little extra coding once type contracts are in place. The type checker does the rest of the work. Like any good tool, TypeScript automates coding discipline while remaining invisible. TypeScript comes with a loud bark yet gentle bite.
If you wanted to have a play around with this project (and I hope you do), you can find the source code for this article on GitHub.