A MERN stack update for 2021. - Part B: Client side.
alanst32
Posted on September 23, 2021
Hello there!
Since my last post my life have changed a lot, I've changed my job. Now I am a lead developer for Westpac Australia, and recently became a father. So it has been difficult to find times to come back here to finish my MERN 2021 article. But at last here we are.
To recap, the purpose of the article is to discuss a modern approach to a MERN stack, having a integration with Cloud solution (Azure) and use modern frameworks on the development like React, Typescript, RxJs and others.
On the first part I have described the server side solution of the MERN stack for our CRUD application. You can review it here: MERN Server-side
Now I will discuss the approach on the client side aspects like:
- Webpack & Typescript ES6 configuration.
- Client side NodeJS server
- Observables with RxJS implementation
- React modern implementation
Test cases
Requirments for this article:
React, NodeJS and Typescript basic knowledge.
MERN CLIENT-SIDE.
1 - Client Project.
The project consists on the UI development of the CRUD via configuration, UI and services implementation. Most of the project was developed by Typescript ES6 instead of standard Javascript. So for the bundle translation, it is used Webpack and Babel.
The CRUD app consists in a simple Students database, the user would be able to insert, delete a student, or to add new Skills.
Frameworks
- React
- Webpack 5
- Babel
- ExpressJS
- Typescript
- RxJS
- Ts-node
Project structure
Node Server
On /server.ts is configured the NodeJs server of the project.
import express from 'express';
import path from 'path';
import bodyParser from 'body-parser';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import {Router} from 'express';
//==================================================================================
const app = express();
app.use(express.static(path.join('./')));
app.use(express.static('./src/components'));
app.use(express.static('./src/assets'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
const router = Router();
router.get('/', (req, res) => {
console.log('got in the client router');
res.render('index');
});
app.use('/', router);
// set engine for rendering
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, './src'));
const PORT = process.env.PORT || 4200;
//Express js listen method to run project on http://localhost:4200
app.listen(PORT, () => console.log(`App is running in ${process.env.NODE_ENV} mode on port ${PORT}`))
/nodemon.json Here we configure nodemon, which is a tool that helps develop node.js based applications by automatically restarting the node application when file changes in the directory are detected..
{
"watch": ["src", "dist"],
"ext": "ts,js,tsx,jsx,ejs,scss,css",
"exec": "ts-node ./server.ts"
}
Understanding the code.
One piece of MERN stack is [ExpressJS], (https://expressjs.com), a flexible Node.js web application framework that provides quick and easy APIs creation. It is through ExpressJs that the client project will access its Api's services. But before that, we need to configure Express in our server. On configuration above we set the static files directory and configure Express to expects requests that have "application/json" Content-Type headers and transform the text-based JSON input into JS-accessible variables under req.body.
Also I set Express to route path "/" to our home page. Then configure the server port to 4200.
As mentioned, I am using Typescript to set the server and rest of the components. Thus, we need to set the transformation of ES6 to CommonJs in the bundle file.
webpack.config.cjs
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpackNodeExternals = require('webpack-node-externals');
const isProduction = typeof NODE_ENV !== 'undefined' && NODE_ENV === 'production';
const devtool = isProduction ? false : 'inline-source-map';
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const clientConfig = {
entry: './src/index.tsx',
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
},
// plugins: [new HtmlWebpackPlugin()],
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '.ejs'],
plugins: [new TsconfigPathsPlugin()],
},
devtool: 'inline-source-map', // Enable to debug typescript code
module: {
rules: [
{
test: /\.(jsx|js)$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'],
}
}
]
},
{
test: /\.(tsx|ts)$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.s[ac]ss$/i,
use: [
// Creates `style` nodes from JS strings
'style-loader',
// Translates CSS into CommonJS
'css-loader',
// Compiles Sass to CSS
'sass-loader',
],
},
{
test: /\.(png|jpg|gif|svg)$/,
use: [
{
loader: 'file-loader',
options: {
outputPath: 'src/images/',
name: '[name][hash].[ext]',
},
},
],
}
]
}
};
module.exports = [clientConfig];
/babel.config.json
{
"presets": [
["@babel/env"],
["@babel/preset-react"]
],
"plugins": ["react-hot-loader/babel"]
}
As the last part of ES6 configuration, I set on ts.config file the bundle file location, the module to be used on the parsing and the most important Module resolution, which in our case is Node.
/tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
"outDir": "./dist/",
"noImplicitAny": false,
"module": "CommonJs",
"target": "ESNext",
"moduleResolution": "node",
"jsx": "react",
"allowJs": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"esModuleInterop" : true,
"typeRoots": [
"./node_modules/@types"
],
"lib": [
"ESNext",
"DOM"
],
"paths": {
"@assets/*": ["assets/*"],
"@app/*": ["components/*"],
"@services/*": ["services/*"],
"@models/*": ["models/*"]
}
},
"include": [
"./",
"./src/assets/index.d.ts"
]
}
App Init Configuration.
Now with server and ES6 parse configuration set we can finally move one with the development of our CRUD UI.
src/index.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Alan Terriaga - MERN stack 2021 updated">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<!-- <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"> -->
<title>MERN 2021</title>
</head>
<body>
<div id="root"></div>
</body>
<script src="../dist/bundle.js"></script>
</html>
src/index.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";
import App from 'App';
ReactDOM.render(<App/>, document.getElementById('root'));
src/App.tsx
import * as React from "react";
import Home from '@app/home';
export default function App() {
return (
<Home></Home>
);
}
Understanding the code.
No secrets so far, on index.ejs we import the common stylesheet libraries and the directory of the javascript bundle file generated by Webpack. Then, we link it to App and Home components as main entry to our CRUD application.
It is now that the app becomes interesting, but before describing our components I would like to show first the service class and how RxJS is used to publish API responses to new events.
Students Service Class.
src/services/student-service.ts
import { RestoreTwoTone } from "@material-ui/icons";
import StudentModel from "@models/student-model";
import axios, { AxiosResponse } from "axios";
import { Subject } from "rxjs";
export interface StudentRequest {
name: string,
skills: string[]
}
// AXIOS
const baseUrl = 'http://localhost:3000';
const headers = {
'Content-Type': 'application/json',
mode: 'cors',
credentials: 'include'
};
const axiosClient = axios;
axiosClient.defaults.baseURL = baseUrl;
axiosClient.defaults.headers = headers;
// GET Clients
const getStudentsSubject = new Subject<StudentModel[]>();
const getStudentsObservable = () => getStudentsSubject.asObservable();
const getStudents = async (body: StudentRequest) => {
axiosClient.post<StudentModel[]>(
'/student/list',
body
)
.then((res) => {
console.log(`res.data: ${JSON.stringify(res.data)}`);
res.data.forEach((res) => res.dateOfBirth = formatDate(res.dateOfBirth));
getStudentsSubject.next(res.data);
})
.catch(ex => console.error(ex));
}
function formatDate(dob: string): string {
const obj = new Date(dob);
const aux = (obj.getMonth()+1);
const month = (aux < 10) ? `0${aux}` : aux;
return `${obj.getDate()}/${month}/${obj.getFullYear()}`;
}
// INSERT STUDENT
const insertStudents = async (body: StudentModel) => {
axiosClient.post(
'/student',
body
)
.catch(ex => console.error(ex));
}
const updateStudent = async (body: StudentModel) => {
axiosClient.put(
'/student',
body
)
.catch(ex => console.error(ex));
}
const deleteStudents = async (ids: string[]) => {
axiosClient.post(
'/student/inactive',
{ids}
)
.then((res) => {
return;
})
.catch(ex => console.error(ex));
}
export {
getStudents,
getStudentsObservable,
insertStudents,
updateStudent,
deleteStudents
}
Understanding the code.
To request the APIS I have decided to use AXIOS as HTTP client, is a framework that has been around for a while and works really well, so I've seen no reason to change it on this matter. If you are not familiar with AXIOS please check its official website Axios-http.
Publishing events on RxJS.
According to the official website RxJS:
"RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects) and operators inspired by Array#extras (map, filter, reduce, every, etc) to allow handling asynchronous events as collections."
In other words, is a framework that allows event asynchronously and any class that subscribe this event will listen the events when triggered. You might are familiar with event based frameworks, we have another examples like Kafka, Redux. I've seen the use of RxJS much more common on Angular application nowadays, although is works really fine in React apps as well.
To understand better how it works, let's get our attention to the GET post. First of all you would need to create a Subject class (Subject is similar to a EventEmitter class) is the only way to multicast messages/objects across the listeners.
With the help of Typescript and ES6 we can use Generics in our favour and map the Subject object as type of StudentModel interface.
Moving forward, you can see that after getting the response of Clients API I am publishing the response object into the Subject class. This will trigger a multicast for the active classes that are listening this event.
In order to reach that goal, you would notice also the Observable object created from the Subject. An Observable represents the idea of an invokable collection of future values or events, is through the Observable we will be able to lister the EventEmitter. Which is out next step.
Components and listening RxJS.
There is a lot to cover here, but to summarise the code we have our Home Component which is divided into StudentForm with the input fields and Insert Function. And StudentTable with the results of GET APIS.
src/components/home/index.tsx
import React, { useEffect, useState } from "react";
import StudentForm from '@app/home/student-form';
import UserTable from '@app/home/student-table';
import {getStudents, getStudentsObservable} from '@services/student-service';
import _ from 'lodash';
import StudentModel from "@models/student-model";
import StudentTable from "@app/home/student-table";
import { makeStyles, Theme, createStyles } from "@material-ui/core";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
home: {
width: '98%',
justifyContent: 'center',
textAlign: 'center',
margin: 'auto'
}
}),
);
export default function Home() {
const classes = useStyles();
const[totalStudents, setTotalStudents] = useState(0);
const[name, setName] = useState('');
const[skills, setSkills] = useState<string[]>([]);
const[students, setStudents] = useState<StudentModel[]>([]);
const emptyStudentModel: StudentModel = {
_id: '',
firstName: '',
lastName: '',
country: '',
dateOfBirth: '',
skills: []
};
useEffect(() => {
const request = {
name,
skills
}
getStudents(request);
}, []);
useEffect(() => {
const subscription = getStudentsObservable().subscribe((list: StudentModel[]) => {
if (!_.isEmpty(list)) {
const size: number = list.length;
const aux: StudentModel[] = list;
setTotalStudents(users => size);
list.forEach(x => x.checked = false);
setStudents(list);
}
else {
setTotalStudents(students => 0);
setStudents(students => []);
}
});
return subscription.unsubscribe;
},[]);
return (
<div className={classes.home}>
<StudentForm totalStudents={totalStudents}></StudentForm>
<StudentTable students={students}></StudentTable>
</div>
);
}
Understanding the code.
Two major factors to pay attention here. First, since I am using functional components instead of React.Component classes, I am using the new (not so new) approach of React Hooks to control props and states changes. Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class. React-Hooks.
I am using the hook UseState to create the state variables and the Hook UseEffect to call Get Students API.
When you use UseEffect hook you tell React that your component needs to do something after render, if you specify a prop in the array you tell React to execute UseEffect only after that prop is changed. However, since I am not specifying a prop, I am telling React to execute UseEffect at first time the component is rendered. One cool feature to highlight here is since we are using Typescript we can set Generic types to our UseState hooks
The second factor here is the use of UseState to listen RxJS event GetStudents from the Observable object. As explained above when the EventEmitter is triggered the Observable class will listen and receive the object specified, in our case the list of Students. Which after that we only need to update our state variables for the next components.
src/components/home/student-form/index.tsx
import { Button, TextField, createStyles, makeStyles, Theme } from "@material-ui/core";
import React, { useState } from "react";
import { Image, Jumbotron } from "react-bootstrap";
import ReactImage from '@assets/svg/react.svg';
import { insertStudents, getStudents } from '@services/student-service';
import StudentModel from "@models/student-model";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
header: {
display: 'inline-block',
width: '100%',
marginBottom: '20px',
},
jumbotron: {
height: '300px',
width: '100%',
display: 'grid',
justifyContent: 'center',
margin: 'auto',
backgroundColor: 'lightblue',
marginBottom: '10px',
},
form: {
display: 'flex',
justifyContent: 'center'
},
infoBox: {
display: 'flex',
justifyContent: 'center',
verticalAlign: 'center'
},
labelStyle: {
fontSize: '32px',
fontWeight: 'bold',
verticalAlign: 'center'
},
insertBtn: {
marginLeft: '20px'
}
}),
);
function JumbotronHeader(props) {
const classes = useStyles();
const { totalStudents } = props;
return (
<Jumbotron className={classes.jumbotron}>
<Image src={ReactImage}/>
<h1>Students skills list: {totalStudents}</h1>
</Jumbotron>
);
}
export default function StudentForm(props) {
const classes = useStyles();
const [firstName, setFirstName ] = useState('');
const [lastName, setLastName] = useState('');
const [country, setCountry] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
async function insertStudentAsync() {
const request: StudentModel = {
firstName,
lastName,
country,
dateOfBirth,
skills: []
};
await insertStudents(request);
await getStudents({
name: '',
skills: []
});
}
const { totalStudents } = props;
return (
<div className={classes.header}>
<JumbotronHeader totalStudents={totalStudents}/>
<form
className={classes.form}
noValidate
autoComplete="off">
<TextField
id="firstName"
label="First Name"
variant="outlined"
onChange={e => setFirstName(e.target.value)}/>
<TextField
id="lastName"
label="Last Name"
variant="outlined"
onChange={e => setLastName(e.target.value)}/>
<TextField
id="country"
label="Country"
variant="outlined"
onChange={e => setCountry(e.target.value)}/>
<TextField
id="dateOfBirth"
label="DOB"
type="date"
variant="outlined"
InputLabelProps={{
shrink: true,
}}
onChange={e => setDateOfBirth(e.target.value)}/>
<Button
id="insertBtn"
className={classes.insertBtn}
variant="contained"
color="primary"
onClick={() => insertStudentAsync()}>
Insert
</Button>
</form>
</div>
);
}
src/components/student-table/index.tsx
import {
Box,
Collapse,
IconButton,
Table,
TableCell,
TableHead,
TableBody,
TableRow,
Typography,
TableContainer,
Checkbox,
Button,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
makeStyles,
TextField,
Theme
} from "@material-ui/core";
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import Paper from '@material-ui/core/Paper';
import React, { useEffect, useState } from "react";
import StudentModel from "@models/student-model";
import { isEmpty } from 'lodash';
import {
getStudents,
updateStudent,
deleteStudents
} from '@services/student-service';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
userTable: {
width: "100%",
marginTop: "20px"
},
innerTable: {
padding: "0px !important"
},
innerBox: {
padding: "16px"
},
innerTableNoBottom: {
padding: "0px !important",
borderBottom: "0px !important"
},
skillsDialog: {
width: "600%"
},
dialog: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
input: {
width: "300px"
},
paper: {
minWidth: "600px",
backgroundColor: theme.palette.background.paper,
border: '2px solid #000',
boxShadow: theme.shadows[5],
padding: theme.spacing(2, 4, 3),
},
}),
);
function getSkillsSummary(skills: string[]) {
const summary: string = new Array(skills).join(",");
return summary.length > 6 ?
`${summary.substring(0, 6)}...` :
summary;
}
function SkillsDialog(props: {
openDialog: boolean,
handleSave,
handleClose,
}) {
const {
openDialog,
handleSave,
handleClose
} = props;
const classes = useStyles();
const [open, setOpen] = useState(false);
const [inputText, setInputText] = useState('');
useEffect(() => {
setOpen(openDialog)
}, [props]);
return (
<Dialog
classes={{ paper: classes.paper}}
open={open}
onClose={handleClose}
aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Add a skill</DialogTitle>
<TextField
autoFocus
className={classes.input}
margin="dense"
id="name"
onChange={e => setInputText(e.target.value)}
/>
<DialogActions>
<Button
color="primary"
onClick={() => handleClose()}>
Cancel
</Button>
<Button
color="primary"
onClick={() => handleSave(inputText)}>
OK
</Button>
</DialogActions>
</Dialog>
)
}
function Row(
props: {
student: StudentModel,
handleCheck
}
) {
const classes = useStyles();
const {student, handleCheck} = props;
const [open, setOpen] = useState(false);
const [openDialog, setOpenDialog] = useState(false);
const openSkillsDialog = () => {
setOpenDialog(true);
}
const closeSkillsDialog = () => {
setOpenDialog(false);
}
async function saveSkillsAsync(newSkill: string) {
const skills = student.skills;
skills.push(newSkill);
const request: StudentModel = {
_id: student._id,
firstName: student.firstName,
lastName: student.lastName,
country: student.country,
dateOfBirth: student.dateOfBirth,
skills: skills
};
await updateStudent(request);
await getStudents({
name: '',
skills: []
});
closeSkillsDialog();
}
return (
<React.Fragment>
<TableRow
className={classes.userTable}
tabIndex={-1}
key={student._id}
role="checkbox">
<TableCell padding="checkbox">
<Checkbox
id={student._id}
onChange={(event) => handleCheck(event, student._id)}
checked={student.checked}
inputProps={{'aria-labelledby': student._id}}/>
</TableCell>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}>
{open ? <KeyboardArrowUpIcon/> : <KeyboardArrowDownIcon/>}
</IconButton>
</TableCell>
<TableCell scope="student">
{`${student.firstName} ${student.lastName}`}
</TableCell>
<TableCell>
{student.dateOfBirth}
</TableCell>
<TableCell>
{student.country}
</TableCell>
<TableCell>
{getSkillsSummary(student.skills)}
</TableCell>
</TableRow>
<TableRow>
<TableCell
className={open ? classes.innerTable: classes.innerTableNoBottom }
colSpan={6}>
<Collapse in={open}
timeout="auto"
unmountOnExit>
<Box className={classes.innerBox}>
<Typography
variant="h5"
gutterBottom
component="div">
Skills
</Typography>
<Table size="small"
aria-label="skills">
<TableBody>
<Button
variant="contained"
color="primary"
onClick={() => openSkillsDialog()}>
Add Skill
</Button>
{student.skills.map((skill) => (
<TableRow key={skill}>
<TableCell
component="th"
scope="skill">
{skill}
</TableCell>
</TableRow>
))}
<SkillsDialog
openDialog={openDialog}
handleClose={closeSkillsDialog}
handleSave={saveSkillsAsync}
/>
</TableBody>
</Table>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default function StudentTable(props: {students: StudentModel[]}) {
const [selectedAll, setSelectedAll] = useState(false);
const [studentList, setStudentList] = useState<StudentModel[]>([]);
useEffect(() => {
setStudentList(props.students);
}, [props]);
const handleCheck = (event, id) => {
const auxList = studentList;
setStudentList((prevList) => {
const aux = prevList.map(s => {
const check = (s._id === id) ? event.target.checked :
s.checked;
return {
_id: s._id,
firstName: s.firstName,
lastName: s.lastName,
dateOfBirth: s.dateOfBirth,
country: s.country,
skills: s.skills,
checked: check
}
});
return aux;
});
}
const handleSelectAll = (event) => {
const check = event.target.checked;
setSelectedAll(check);
setStudentList((prevList) => {
const aux = prevList.map(s => {
return {
_id: s._id,
firstName: s.firstName,
lastName: s.lastName,
dateOfBirth: s.dateOfBirth,
country: s.country,
skills: s.skills,
checked: check
}
});
return aux;
});
}
useEffect(()=> {
if(!isEmpty(studentList)) {
const filter = studentList.filter(s => !s.checked);
setSelectedAll((prevChecked) => isEmpty(filter));
}
}, [studentList]);
async function deleteStudentsAsync() {
const filter: string[] = studentList
.filter(s => s.checked === true)
.map(x => x._id || '');
if (!isEmpty(filter)) {
await deleteStudents(filter);
await getStudents({
name: '',
skills: []
});
}
}
return (
<TableContainer component={Paper}>
<Table aria-label="collapsible table">
<TableHead>
<TableRow>
<TableCell>
<Checkbox
value={selectedAll}
checked={selectedAll}
onChange={(event) => handleSelectAll(event)}
inputProps={{ 'aria-label': 'Select all students' }} />
</TableCell>
<TableCell>
<Button
variant="contained"
color="primary"
onClick={() => deleteStudentsAsync()}>
Delete
</Button>
</TableCell>
<TableCell>Name</TableCell>
<TableCell>DOB</TableCell>
<TableCell>Country</TableCell>
<TableCell>Skills</TableCell>
</TableRow>
</TableHead>
<TableBody>
{studentList.map((row) => {
return (
<Row
key={row._id}
student={row}
handleCheck={handleCheck} />
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
Understanding the code.
For the rest of the components, there is nothing here to explain that we didn't covered on the Home component above. The only exception is when we insert a new Student, the Get method is called straight after which eventually will generate a new event and trigger GetStudents observable to update the list.
I hope I could be clear with this enormous post, and if you have stayed with me until this end, thank you very much.
Don't forget to check it out the project on Github: mern-azure-client
Please feel free to comments for suggestions or tips.
See ya.
Alan Terriaga.
Posted on September 23, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.