How to Build a Basic CRUD App with NextJS, React, Redux, Observable and MUI5 Components

czetsuya
11 min readJan 30, 2022

Learn how to build a NextJS application that uses `redux-observable` to manage side effects.

1. Introduction

In this project, we will learn how to build a basic CRUD application using NextJS and Redux & Observable to manage the side effects.

Note: we are not using `AjaxObservable` from the `rxjs` library; as of rxjs v5.5.6, it will not work on both the server and client-side. Instead we call the default export from universal-rxjs-ajax (as `request`).

We transform the Observable we get from `ajax` into a Promise in order to await its resolution. That resolution should be an action (since the epic returns Observables of actions). We immediately dispatch that action to the store.

This server-side solution allows compatibility with Next. It may not be something you wish to emulate. In other situations, calling or awaiting epics directly and passing their result to the store would be an anti-pattern. You should only trigger epics by dispatching actions. This solution may not generalise to resolving more complicated sets of actions.

The layout of the redux related functionality uses the redux-ducks pattern.

Except in those manners discussed above, the configuration is similar to the configuration found in with-redux example on the NextJS repository and redux-observable docs.

2. Tech Stack

  • NextJS — React framework
  • Redux — state management
  • Observable — middleware
  • MUI5 — UI components
  • Formik — form management
  • Yup — form validation
  • Prisma — database

3. Coding

3.1 Setup the database

In this example, we are using Prisma which is a light-weight server side database as our data store. To enable Prisma in our project we need to add the following dependencies:

  • “@prisma/client”: “³.8.0”
  • “prisma”: “³.8.0” — devDependencies

Create an initial schema and migration SQL inside src/prisma folder.

datasource db {
provider = “sqlite”
url = env(“DATABASE_URL”)
}

generator client {
provider = “prisma-client-js”
binaryTargets = [“native”]
}

model User {
id Int @id @default(autoincrement())
firstName String
lastName String
email String?
birthDate DateTime?
}

And migration inside prisma/migrations/<migration_folder_dated>/migration_file.sql

 — CreateTable
CREATE TABLE “User” (
“id” INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
“firstName” TEXT NOT NULL,
“lastName” TEXT NOT NULL,
“email” TEXT,
“birthDate” DATETIME
);

To build the local database, execute:

npx prisma migrate dev — name=init

The next step is whether you want to setup the redux store or view. It really depends on your programming style. As for me, I normally go with the store or core of the framework.

4. Setup the Application Store

4.1 Application Store

The application store is derived from the NextJS example, it persists the combined reduces using an AsyncStorage and run the epics in the middleware.

4.2 User Module

Using the redux-ducks pattern we create a single module file for our user. It contain the actionTypes, actions, reducers, and epics. This should make editing and maintenance easier.

Here is our User module, with the basic CRUD features. It uses epics, which is basically a function that takes a stream of actions and returns a stream of actions. And as we can see here, one of its application is when calling a REST API. What happens is that we initiate a redux action, this action pass in the redux middleware and executes and epic when one is defined for an action type.

import {
of
} from 'rxjs';
import {
catchError,
map,
mergeMap
} from 'rxjs/operators';
import {
ofType
} from 'redux-observable';
import {
actionStates
} from '../ModuleUtil';
import RxBackend from '../../utils/RxBackend';
// CONSTANTS - START
const INITIAL_STATE = {
error: null,
status: {
inserted: false,
updated: false,
deleted: false
},
};
// CONSTANTS - END
// ACTION TYPES - START
const RETRIEVE_LIST = actionStates('actions/USER_RETRIEVE_LIST');
const RETRIEVE_USER = actionStates('actions/RETRIEVE_USER');
const CREATE_USER = actionStates('actions/CREATE_USER');
const UPDATE_USER = actionStates('actions/UPDATE_USER');
const DELETE_USER = actionStates('actions/DELETE_USER');
const REDIRECT_TO_USERS_PAGE = actionStates('actions/REDIRECT_TO_USERS_PAGE');
const CLEAR_USER = "actions/CLEAR_USER";
const CLEAR_USER_STATUS = "actions/CLEAR_USER_STATUS";
// ACTION TYPES - END
// REDUCER - START
export const reducer = (state = INITIAL_STATE, {
type,
payload
}) => {
state = {

state,
list: undefined
};
switch (type) {
case RETRIEVE_LIST.SUCCESS:
return {

state,
users: payload.users,
count: payload.count
};
case RETRIEVE_USER.SUCCESS:
return {

state,
user: payload.user
};
case RETRIEVE_LIST.ERROR:
return {

state,
error: payload.error,
};
case CREATE_USER.SUCCESS:
return {

state,
user: payload.user,
status: {

state.status,
inserted: true
},
};
case UPDATE_USER.SUCCESS:
return {

state,
user: payload.user,
status: {

state.status,
updated: true
},
};
case DELETE_USER.SUCCESS:
return {

state,
user: payload.user,
status: {

state.status,
deleted: true
},
};
case CLEAR_USER_STATUS:
return {

state,
status: {

INITIAL_STATE.status
}
}
case CLEAR_USER:
return {

state,
user: null
}
default:
return state;
}
};
// REDUCER - END
// ACTIONS - START
export const retrieveList = ({
offset,
limit
}) => ({
type: RETRIEVE_LIST.START,
payload: {
offset,
limit
},
});
export const retrieveUsersOk = ({
users,
count
}) => ({
type: RETRIEVE_LIST.SUCCESS,
payload: {
users,
count
},
});
export const retrieveUsersKo = ({
status,
name,
message
}) => ({
type: RETRIEVE_LIST.ERROR,
payload: {
status,
name,
message
},
});
export const retrieveUser = (userId) => ({
type: RETRIEVE_USER.START,
payload: {
userId
}
});
export const retrieveUserOk = ({
user
}) => ({
type: RETRIEVE_USER.SUCCESS,
payload: {
user
}
});
export const retrieveUserKo = ({
status,
name,
message
}) => ({
type: RETRIEVE_USER.ERROR,
payload: {
status,
name,
message
},
});
export const createUser = (user, router) => ({
type: CREATE_USER.START,
payload: {
user,
router
}
});
export const createUserOk = (router) => ({
type: CREATE_USER.SUCCESS,
payload: {
router
}
});
export const createUserKo = ({
status,
name,
message
}) => ({
type: CREATE_USER.ERROR,
payload: {
status,
name,
message
},
});
export const updateUser = (user, router) => ({
type: UPDATE_USER.START,
payload: {
user,
router
}
});
export const updateUserOk = (router) => ({
type: UPDATE_USER.SUCCESS,
payload: {
router
}
});
export const updateUserKo = ({
status,
name,
message
}) => ({
type: UPDATE_USER.ERROR,
payload: {
status,
name,
message
},
});
export const deleteUser = ({
userId
}) => ({
type: DELETE_USER.START,
payload: {
userId
}
});
export const deleteUserOk = (user) => ({
type: DELETE_USER.SUCCESS,
payload: {
user
}
});
export const deleteUserKo = () => ({
type: DELETE_USER.ERROR,
payload: {}
});
export const clearUser = () => ({
type: CLEAR_USER
});
export const clearUserStatus = () => ({
type: CLEAR_USER_STATUS,
});
export const redirectToUsersPageOk = () => ({
type: REDIRECT_TO_USERS_PAGE.SUCCESS,
});
// ACTIONS - END// EPICS - START
const retrieveUsersEpic = (action$, state$) =>
action$.pipe(
ofType(RETRIEVE_LIST.START),
mergeMap((action) => {
const {
limit,
offset
} = action.payload;
return RxBackend.ajaxGet({
url: `api/users?limit=${limit}&offset=${offset}`
}).pipe(
map((resp) => retrieveUsersOk({
users: resp.response.users,
count: resp.response.count
})),
catchError((err) => {
const {
status,
name,
message
} = err;
return of(retrieveUsersKo({
status,
name,
message
}));
}),
);
})
);
const retrieveUserEpic = (action$, state$) =>
action$.pipe(
ofType(RETRIEVE_USER.START),
mergeMap((action) => {
const {
userId
} = action.payload;
return RxBackend.ajaxGet({
url: `api/users/${userId}`
}).pipe(
map((resp) => retrieveUserOk({
user: resp.response
})),
catchError((err) => {
const {
status,
name,
message
} = err;
return of(retrieveUserKo({
status,
name,
message
}));
}),
)
})
);
const createUserEpic = (action$, state$) =>
action$.pipe(
ofType(CREATE_USER.START),
mergeMap(action => {
const {
user,
router
} = action.payload;
const newUser = Object.keys(user).reduce((userValues, key) => {
const value = user[key];
return !value ? userValues : {

userValues,
[key]: value
};
}, {});
console.log("posting new user", newUser);
return RxBackend.ajaxPost({
url: `api/users`,
body: {

newUser
}
}).pipe(
map(resp => createUserOk(router)),
catchError(err => {
const {
status,
name,
message
} = err;
return of(createUserKo({
status,
name,
message
}));
})
)
})
);
const createUserOkEpic = (action$, state$) =>
action$.pipe(
ofType(CREATE_USER.SUCCESS),
map(action => {
const {
router
} = action.payload;
console.log("create user ok", router)
router.push("/users");
return redirectToUsersPageOk();
})
);
const updateUserEpic = (action$, state$) =>
action$.pipe(
ofType(UPDATE_USER.START),
mergeMap(action => {
const {
user,
router
} = action.payload;
const newUser = Object.keys(user).reduce((userValues, key) => {
const value = user[key];
return !value ? userValues : {

userValues,
[key]: value
};
}, {});
console.log("put user", newUser);
return RxBackend.ajaxPut({
url: `api/users/${user.id}`,
body: {

newUser
}
}).pipe(
map(resp => updateUserOk(router)),
catchError(err => {
const {
status,
name,
message
} = err;
return of(updateUserKo({
status,
name,
message
}));
})
)
})
);
const updateUserOkEpic = (action$, state$) =>
action$.pipe(
ofType(UPDATE_USER.SUCCESS),
map(action => {
const {
router
} = action.payload;
console.log("update user ok", router)
router.push("/users");
return redirectToUsersPageOk();
})
);
const deleteUserEpic = (action$, state$) =>
action$.pipe(
ofType(DELETE_USER.START),
mergeMap(action => {
const {
userId
} = action.payload;
return RxBackend.ajaxDelete({
url: `api/users/${userId}`
}).pipe(
map(resp => deleteUserOk(resp.response)),
catchError(err => {
const {
status,
name,
message
} = err;
return of(deleteUserKo({
status,
name,
message
}));
})
)
})
);
// EPICS - END
export const epics = [
retrieveUsersEpic,
retrieveUserEpic,
createUserEpic,
createUserOkEpic,
updateUserEpic,
updateUserOkEpic,
deleteUserEpic,
];

Notice that every actionState is expanded by several states like start, loading, success, error, etc.

This module exports a reducer and epics which we need to add in our main reducer and we call this file “Modules”

import {epics as userEpics, reducer as user} from ‘./modules/UserModule.js’;

export const reducers = {
user
};

export const epics = […userEpics];

As mentioned earlier, we are using universal-rxjx-ajax, to issue HTTP requests. To make calling it easier, we created a simple utility class for the most common HTTP methods.

import {
request
} from "universal-rxjs-ajax";
const defaultHeaders = {
Authorization: 'czetsuyatech'
};
export const getHeaders = (headers) =>
Object.assign({}, defaultHeaders, headers);
const ajaxRequest = (options) => {options.url = process.env.NEXT_PUBLIC_API_URL + options.url;
return request(options);
}
const ajaxGet = ({
url = "/",
headers = {}
}) => {
const config = {
url,
method: 'GET',
headers: getHeaders(headers)
}
return ajaxRequest(config);
}
const ajaxPost = ({
url = "/",
headers = {},
body = {}
}) => {
const config = {
url,
method: 'POST',
headers: getHeaders(headers),
body
}
return ajaxRequest(config);
}
const ajaxPut = ({
url = "/",
headers = {},
body = {}
}) => {
const config = {
url,
method: 'PUT',
headers: getHeaders(headers),
body
}
return ajaxRequest(config);
}
const ajaxPatch = ({
url = "/",
headers = {},
body = {}
}) => {
const config = {
url,
method: 'PATCH',
headers: getHeaders(headers),
body
}
return ajaxRequest(config);
}
const ajaxDelete = ({
url = "/",
headers = {}
}) => {
const config = {
url,
method: 'DELETE',
headers: getHeaders(headers)
}
return ajaxRequest(config);
}
const RxBackend = {
ajaxGet,
ajaxPost,
ajaxPut,
ajaxPatch,
ajaxDelete
}
export default RxBackend;

5. UI Side

In the UI we implemented a traditional list, detail (add/edit) pages. Where the id of the user is passed in the detail page to edit and null to add a new record.

Here is the User list page that uses MUI5 components to render a table. On page load implemented using useEffect, it calls an API to retrieve the list of users. Delete action is also implemented on this page.

import React, {useEffect, useState} from ‘react’;
import {
Button,
ButtonGroup,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Link,
Snackbar,
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow
} from “@mui/material”;
import {useDispatch, useSelector} from “react-redux”;
import moment from “moment”;
import {Delete, Edit, PersonAdd} from “@mui/icons-material”;

import {clearUser, clearUserStatus, deleteUser, retrieveList,} from ‘redux/modules/UserModule’;
import {useRouter} from “next/router”;
import Footer from “../Footer/Footer”;

const useUsers = () =>
useSelector(({user: {users, count, status, user}}) => ({
users,
count,
status,
storeUser: user
}));

const EMPTY_DIALOG = {
open: false,
text: ‘’,
title: ‘’,
onConfirm: () => null,
onCancel: () => null
}

const EMPTY_ALERT = {
open: false,
text: ‘’,
};

const Users = () => {

const router = useRouter();
const dispatch = useDispatch();
const {users, count, status, storeUser} = useUsers();
const hasUsers = !!users && users.length > 0;
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(10);
const [dialog, setDialog] = useState(EMPTY_DIALOG);
const [alert, setAlert] = useState(EMPTY_ALERT);

useEffect(() => {
dispatch(retrieveList({offset: offset * limit, limit}));
}, [dispatch, offset, limit]);

useEffect(() => {
if (status.deleted) {
resetDialog();
dispatch(retrieveList({offset: offset * limit, limit}));
setAlert({
open: true,
text: `Successfully deleted user: ${storeUser.id}`,
});
}
}, [status, offset, limit, dispatch]);

const handleChangeRowsPerPage = ({target: {value}}) => {
setLimit(value);
};

const handleChangePage = (_, nextPage) => {
setOffset(nextPage);
};

const handleDeleteUser = ({id}) => () => {
dispatch(deleteUser({userId: id}));
};

const resetDialog = () => {
setDialog(EMPTY_DIALOG);
}

const resetAlert = () => {
setAlert(EMPTY_ALERT);
dispatch(clearUserStatus());
dispatch(clearUser());
};

const openDialog = (user) => () => {
setDialog({
open: true,
title: ‘Delete user’,
text: `Delete user: ${user.id}?`,
onConfirm: handleDeleteUser(user),
onCancel: () => resetDialog()
});
};

const editUser = ({id}) => () => {
router.push(`/users/${id}`);
}

return (
<Container maxWidth={“md”} fixed>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell colSpan={6} align=”right”>
<Link href=”/users/new”>
<Button variant=”outlined” color=”primary”>
<PersonAdd/>
</Button>
</Link>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Id</TableCell>
<TableCell>First name</TableCell>
<TableCell>Last name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Birth date</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{hasUsers ? (
users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.firstName}</TableCell>
<TableCell>{user.lastName}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{moment.utc(user.birthDate).format(‘MM-DD-YYYY’)}
</TableCell>
<TableCell sx={{textAlign: “right”}}>
<ButtonGroup>
<Button onClick={editUser(user)}>
<Edit/>
</Button>
<Button onClick={openDialog(user)}>
{<Delete/>}
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6}>No users found.</TableCell>
</TableRow>
)}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
count={count}
page={offset}
rowsPerPage={limit}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
<Footer></Footer>
<Dialog
open={dialog.open}
onClose={dialog.onCancel}
aria-labelledby=”alert-dialog-title”
aria-describedby=”alert-dialog-description”
>
<DialogTitle id=”alert-dialog-title”>
{dialog.title}
</DialogTitle>
<DialogContent>
<DialogContentText id=”alert-dialog-description”>
{dialog.text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={dialog.onCancel}>Disagree</Button>
<Button onClick={dialog.onConfirm} autoFocus>
Agree
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={alert.open}
autoHideDuration={6000}
onClose={resetAlert}
message={alert.text}
/>
</Container>
);
}

export default Users;

And here is the User detail page. We use formik to build the form and yup to define the validation rules of it, this is triggered when we submit the form.

This is very important, notice that inside the useEffect we are fetching the user given an id, this useEffect will only be triggered if the value of user changes to avoid too many re-renders error. If the user exists, we will the values of the form fields using formik.

import React, {useEffect} from ‘react’;
import {Alert, Box, Button, Container, Grid, Link, TextField, Typography} from “@mui/material”;
import {useRouter} from “next/router”;
import {useDispatch, useSelector} from “react-redux”;
import * as yup from ‘yup’;
import {useFormik} from “formik”;
import AdapterMoment from ‘@mui/lab/AdapterMoment’;
import LocalizationProvider from ‘@mui/lab/LocalizationProvider’;
import {DatePicker} from “@mui/lab”;
import Footer from “../Footer/Footer”;
import {clearUser, clearUserStatus, createUser, retrieveUser, updateUser} from ‘../../redux/modules/UserModule’;
import moment from “moment”;

const validationSchema = yup.object({
email: yup
.string()
.trim()
.email(‘Please enter a valid email address’)
.required(‘Email is required.’),
firstName: yup
.string()
.required(‘Please specify your first name’),
lastName: yup
.string()
.required(‘Please specify your first name’),
birthDate: yup
.date()
});

const INITIAL_USER = {
firstName: ‘’,
lastName: ‘’,
email: ‘’
}

const useUser = () => useSelector(({user: {user, status}}) => ({
user,
status
}));

const UserDetail = () => {

const router = useRouter();
const dispatch = useDispatch();
const {user, status} = useUser();
const [birthDate, setBirthDate] = React.useState(null);
const {id} = router.query;

useEffect(() => {

if (id && !isNaN(id)) {
dispatch(retrieveUser(id));
}
}, [id]);

useEffect(() => {
if (user && user !== null) {
console.log(‘formik.setValues’)
setBirthDate(moment(user.birthDate));

formik.setValues({
firstName: user.firstName,
lastName: user.lastName,
email: user.email
});
}
}, [user]);

useEffect(() => {
return () => {
console.log(“clearing status”);
dispatch(clearUser());
dispatch(clearUserStatus());
};
}, [router]);

const onSubmit = (values) => {
let newValues = {
…values,
birthDate: birthDate.toISOString()
}

if (user && user.id) {
newValues.id = user.id;
dispatch(updateUser(newValues, router));

} else {
dispatch(createUser(newValues, router));
}
};

const formik = useFormik({
initialValues: INITIAL_USER,
validationSchema: validationSchema,
onSubmit
});

return (
<Container maxWidth={“sm”}>
<Alert severity=”error”></Alert>
<Box marginBottom={4}>
<Typography
sx={{
textTransform: ‘uppercase’,
fontWeight: ‘medium’,
}}
gutterBottom
color={‘text.secondary’}
>
Create User
</Typography>
<Typography color=”text.secondary”>
Enter the details
</Typography>
</Box>
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4}>
<Grid item xs={12}>
<Typography variant={‘subtitle2’} sx={{marginBottom: 2}}>
Enter your email
</Typography>
<TextField
label=”Email *”
variant=”outlined”
name={‘email’}
fullWidth
value={formik.values.email}
onChange={formik.handleChange}
error={formik.touched.email && Boolean(formik.errors.email)}
helperText={formik.touched.email && formik.errors.email}
/>
</Grid>
<Grid item xs={12}>
<Typography variant={‘subtitle2’} sx={{marginBottom: 2}}>
Enter your firstname
</Typography>
<TextField
label=”Firstname *”
variant=”outlined”
name={‘firstName’}
fullWidth
value={formik.values.firstName}
onChange={formik.handleChange}
error={formik.touched.firstName && Boolean(formik.errors.firstName)}
helperText={formik.touched.firstName && formik.errors.firstName}
/>
</Grid>
<Grid item xs={12}>
<Typography variant={‘subtitle2’} sx={{marginBottom: 2}}>
Enter your lastName
</Typography>
<TextField
label=”Lastname *”
variant=”outlined”
name={‘lastName’}
fullWidth
value={formik.values.lastName}
onChange={formik.handleChange}
error={formik.touched.lastName && Boolean(formik.errors.lastName)}
helperText={formik.touched.lastName && formik.errors.lastName}
/>
</Grid>
<Grid item xs={12}>
<Typography variant={‘subtitle2’} sx={{marginBottom: 2}}>
Enter your birthdate
</Typography>
<LocalizationProvider dateAdapter={AdapterMoment}>
<DatePicker
fullWidth
label=”Birthdate”
value={birthDate}
onChange={(newValue) => {
setBirthDate(newValue);
}}
renderInput={(params) => <TextField {…params} variant={“outlined”} fullWidth/>}
/>
</LocalizationProvider>
</Grid>
<Grid item xs={12}>
<Typography variant=”subtitle2" gutterBottom>
Fields that are marked with * sign are required.
</Typography>
<Grid container spacing={2}>
<Grid item>
<Button
size=”large”
variant=”contained”
color=”primary”
type={“submit”}
>
Save
</Button>
</Grid>
<Grid item>
<Link href=”/users”>
<Button size=”large” variant=”contained” color=”secondary”>
Cancel
</Button>
</Link>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
<Footer></Footer>
</Container>
);
}

export default UserDetail;

Originally published with video and git repository at https://www.czetsuyatech.com/2022/01/react-build-basic-crud-app-with-nextjs-redux-observable-and-mui5.html

--

--

czetsuya

Open for Collaboration | Senior Java Backend Developer