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;
The post A Deep Dive into Redux appeared first on SitePoint.
No comments:
Post a Comment