A Deep Dive into Redux

Originally published at: https://www.sitepoint.com/redux-deep-dive/

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;

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.