Core points
Building a stateful modern application is a complex task. As the state changes, the application becomes unpredictable and difficult to maintain. This is where Redux comes in. Redux is a lightweight library for handling state. Think of it as a state machine.
In this article, I will explore Redux's state containers in depth by building a payroll processing engine. The app will store payrolls along with all the extras such as bonuses and stock options. I'll use pure JavaScript and TypeScript for type checking to keep the solution simple. Since Redux is very easy to test, I will also use Jest to verify the application.
In this tutorial, I assume you have some understanding of JavaScript, Node, and npm.
First, you can initialize this application with npm:
npm init
When asking for test commands, continue to use jest. This means that npm t will start Jest and run all unit tests. The main file will be index.js to keep it simple. Feel free to answer the rest of npm init questions.
I will use TypeScript to do type checking and determine the data model. This helps conceptualize what we are trying to build.
To get started with TypeScript:
npm i typescript --save-dev
I will put some of the dependencies in the development workflow in devDependencies. This clearly shows which dependencies are prepared for developers and which dependencies will be used in production environments. Once TypeScript is ready, add a startup script in package.json:
"start": "tsc && node .bin/index.js"
Create an index.ts file under the src folder. This separates the source file from the rest of the project. If you do npm start, the solution won't be executed. This is because you 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 the tsc command line argument. For example, tsc src/index.ts --strict .... But putting all of this in a separate file is much clearer. Note that the startup script in package.json only requires a tsc command.
Here are some reasonable compiler options that will give us a good starting point and what each option means:
Because I will use Jest for unit testing, I will continue to add it:
npm init
ts-jest dependency adds type checking for the test framework. One thing to note is to add a jest configuration in package.json:
npm i typescript --save-dev
This allows the test framework to pick up TypeScript files and know how to convert them. A nice feature is that you can do type checking when running unit tests. To make sure this project is ready, create a __tests__ folder containing an index.test.ts file. Then, a sanitation check is performed. For example:
"start": "tsc && node .bin/index.js"
Now execute npm start and npm t will not cause any errors. This tells us that we can now start building solutions. But before we do this, let's add Redux to the project:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
This dependency will be used in production environments. Therefore, there is no need to include it with --save-dev. If you check your package.json, it will be in dependencies.
The payroll engine will contain the following: wages, reimbursements, bonuses and stock options. In Redux, you cannot update the status directly. Instead, an action is scheduled to notify storage of any new changes.
So this leaves the following operation type:
npm i jest ts-jest @types/jest @types/node --save-dev
PAY_DAY operation type can be used to issue checks on payday and track salary history. These types of operations guide the rest of the design as we perfect the payroll engine. They capture events in the state life cycle, such as setting the base wage amount. These action events can be attached to any content, whether it is a click event or a data update. Redux operation types are abstract about where scheduling comes from. The status container can run on the client and/or on the server.
Using type theory, I will determine the data model based on the state data. For each payroll operation, such as the operation type and optional amount. The amount is optional because PAY_DAY does not require funds to process the payroll. I mean, it can charge customers, but ignore it for now (maybe introduced in the second edition).
For example, put it in src/index.ts:
"jest": { "preset": "ts-jest" }
For payroll status, we need an attribute for basic salary, bonus, etc. We will also use this status to maintain salary history.
This TypeScript interface should do:
npm init
For each property, note that TypeScript uses a colon to specify the type. For example, : number. This determines the type contract and adds predictability to the type checker. Redux can be enhanced using a type system with explicit type declarations. This is because Redux state containers are built for predictable behavior.
This idea is not crazy or radical. Learning Redux Chapter 1 (SitePoint Premium members only) explains this well.
As the application changes, type checking adds additional predictability. As applications expand, type theory also helps to simplify the reconstruction of large code segments.
Using the type conceptualization engine now helps create the following operation functions:
npm i typescript --save-dev
The good thing is that if you try to do processBasePay('abc'), the type checker will warn you. Destroying type contracts reduces predictability of state containers. I use a single operation contract like PayrollAction to make the payroll processor more predictable. Note that the amount is set in the operation object through the ES6 attribute abbreviation. The more traditional approach is amount: amount, which is more verbose. Arrow functions, such as () => ({}), are a concise way to write functions that return object literals.
Example:
"start": "tsc && node .bin/index.js"
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
npm i jest ts-jest @types/jest @types/node --save-dev
Start the switch statement of reducer to handle the first use case:
"jest": { "preset": "ts-jest" }
it('is true', () => { expect(true).toBe(true); });
npm init
Since the amount is optional, make sure it has a default value to reduce failure. This is the advantage of TypeScript, as the type checker will spot this trap and warn you. The type system knows certain facts, so it can make reasonable assumptions. Suppose you want to deal with the bonus:
npm i typescript --save-dev
This mode makes the reducer readable because it maintains only the state. You get the amount of the operation, calculate the total salary, and create a new object text. Nothing is different when dealing with stock options:
"start": "tsc && node .bin/index.js"
For processing payroll on payday, it requires erasing bonuses and reimbursement. These two attributes are not kept in the state in each payroll. And, add an entry to the salary history. Basic wages and stock options can be kept in the state because they do not change frequently. With that in mind, this is how PAY_DAY is handled:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
In an array like newPayHistory, use the extension operator, which is the antonym for rest. Unlike rest of the property in the collection object, it expands the project. For example, [...payHistory]. Although the two operators look similar, they are not the same. Watch carefully, as this may appear in interview questions.
Using pop() for payHistory will not change the state. Why? Because slice() returns a brand new array. Arrays in JavaScript are copied by reference. Assigning an array to a new variable does not change the underlying object. Therefore, care must be taken when dealing with these types of objects.
Because lastPayHistory is likely undefined, I use the poor man's null value merge to initialize it to zero. Please note that (o && o.property) || 0 mode is used for merging. There may be a more elegant way to do this in future versions of JavaScript or even TypeScript.
Each Redux reducer must define a default branch. To ensure that the state does not become undefined:
npm i jest ts-jest @types/jest @types/node --save-dev
One of the many benefits of writing pure functions is that they are easy to test. Unit testing is a test where you have to expect predictable behavior, and you can automate all tests as part of the build. In __tests__/index.test.ts, cancel the virtual test and import all the functions of interest:
"jest": { "preset": "ts-jest" }
Note that all functions are set to export, so you can import them. For basic salary, start the payroll engine reducer and test it:
it('is true', () => { expect(true).toBe(true); });
Redux Sets the initial state to undefined. Therefore, it is always a good idea to provide default values in the reducer function. How about handling reimbursement?
npm i redux --save
The pattern of handling bonuses is the same as this:
const BASE_PAY = 'BASE_PAY'; const REIMBURSEMENT = 'REIMBURSEMENT'; const BONUS = 'BONUS'; const STOCK_OPTIONS = 'STOCK_OPTIONS'; const PAY_DAY = 'PAY_DAY';
For stock options:
interface PayrollAction { type: string; amount?: number; }
Note that when stockOptions is greater than totalPay, totalPay must remain unchanged. Since this hypothetical company is ethical, it does not want to take money from its employees. If you run this test, please note that totalPay is set to -10, because stockOptions will be deducted. This is why we test the code! Let's fix the place where the total salary is calculated:
npm init
If the money earned by employees does not have enough money to buy company stocks, please continue to skip the deduction. Also, make sure it resets stockOptions to zero:
npm i typescript --save-dev
This fix determines whether they have enough money in newStockOptions. With this, the unit test passes, the code is sound and meaningful. We can test positive use cases where there is enough money to make deductions:
"start": "tsc && node .bin/index.js"
For paydays, use multiple statuses to test and make sure a one-time transaction does not persist:
{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] }
Note how I adjust oldState to verify the bonus and reset the reimbursement to zero.
What about the default branch in reducer?
npm i jest ts-jest @types/jest @types/node --save-dev
Redux sets an operation type like INIT_ACTION at the beginning. We only care whether our reducer has some initial state set.
At this point, you may start to wonder if Redux is more of a design pattern. If you answer that it is both a pattern and a lightweight library, you are right. In index.ts, import Redux:
"jest": { "preset": "ts-jest" }
The next code example can be wrapped around this if statement. This is a stopgap so unit tests don't leak into integration tests:
it('is true', () => { expect(true).toBe(true); });
I do not recommend doing this in actual projects. Modules can be placed in separate files to isolate components. This makes it easier to read and does not leak problems. Unit testing also benefits from the fact that modules run independently.
Use payrollEngineReducer to start Redux storage:
npm i redux --save
Each store.subscribe() returns a subsequent unsubscribe() function that can be used for cleaning. It unsubscribes to the callback when it is scheduled through the storage. Here I use store.getState() to output the current state to the console.
Suppose the employee earned 300, had 50 reimbursements, 100 bonuses, and 15 for the company's stock:
const BASE_PAY = 'BASE_PAY'; const REIMBURSEMENT = 'REIMBURSEMENT'; const BONUS = 'BONUS'; const STOCK_OPTIONS = 'STOCK_OPTIONS'; const PAY_DAY = 'PAY_DAY';
To make it more fun, make another 50 reimbursement and process another payroll:
interface PayrollAction { type: string; amount?: number; }
Finally, run another payroll and unsubscribe to Redux storage:
interface PayStubState { basePay: number; reimbursement: number; bonus: number; stockOptions: number; totalPay: number; payHistory: Array<PayHistoryState>; } interface PayHistoryState { totalPay: number; totalCompensation: number; }
The final result is as follows:
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});
As shown, Redux maintains state, changes state and notifies subscribers in a neat small package. Think of Redux as a state machine, which is the true source of state data. All of this adopts coding best practices, such as a sound functional paradigm.
Redux provides a simple solution to complex state management problems. It relies on the functional paradigm to reduce unpredictability. Because reducer is a pure function, unit testing is very easy. I decided to use Jest, but any test framework that supports basic assertions will work.
TypeScript uses type theory to add an additional layer of protection. Combine type checking with functional programming and you get sturdy code that is almost never interrupted. Most importantly, TypeScript does not get in the way of working while adding value. If you notice, once the type contract is in place, there is almost no additional encoding. The type checker does the rest. Like any good tool, TypeScript automates encoding discipline while remaining invisible. TypeScript barking loudly, but it bites lightly.
If you want to try this project (I hope you do this), you can find the source code for this article on GitHub.
The above is the detailed content of A Deep Dive into Redux. For more information, please follow other related articles on the PHP Chinese website!