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 Typescript and Redux-Toolkit to manage the side effects.
2. Tech Stack
- NextJS - React framework
- Typescript
- Redux-Toolkit - a toolset for redux state management
- MUI5 - UI components
- Formik - form management
- Yup - form validation
- Prisma - database
3. Coding
3.1 Setup the database
In this example, we use Prisma, a lightweight server-side database, as our data store. To enable Prisma in our project, we need to add the following dependencies:
- "@prisma/client": "^3.8.0"
- "prisma": "^3.8.0" - devDependencies
Create the Prisma data source configuration
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 initial schema and migration SQL inside src/prisma folder.
-- 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 set up the redux store or view. It 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 reducers and middlewares from Redux-toolkit's createApi.
import {configureStore} from '@reduxjs/toolkit';
import {UsersService} from "./UserService";
import {UserSlice} from "./slices/UserSlice";
export function makeStore() {
return configureStore({
reducer: {
[UsersService.reducerPath]: UsersService.reducer,
user: UserSlice.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
UsersService.middleware
),
})
}
const store = makeStore()
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {users: UsersState}
export type AppDispatch = typeof store.dispatch
export default store;
4.2 User Service/API
This file contains all the API endpoints for the user which uses query and mutation to edit the user's data. It uses the header defined in the BaseService file.
import {BaseService} from "./BaseService";
import {UserType} from "./types/UserType";
import {createSlice} from "@reduxjs/toolkit";
interface Pagination {
offset: number,
limit: number
}
export const UsersService = BaseService.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<UserType[], Pagination>({
query: (param: Pagination) => `/users?offset=${param.offset}&limit=${param.limit}`,
providesTags: [{type: "User", id: "LIST"}]
}),
getUser: build.query<UserType, number>({
query: (id) => ({
url: `/users/${id}`,
})
}),
createUser: build.mutation<UserType, UserType>({
query: (body: UserType) => ({
url: `/users`,
method: 'POST',
body
}),
invalidatesTags: [{type: "User", id: "LIST"}]
}),
updateUser: build.mutation<UserType, Pick<UserType, 'id'> & Partial<UserType>>({
query: ({id, ...body}) => ({
url: `/users/${id}`,
method: 'PATCH',
body
}),
invalidatesTags: [{type: "User", id: "LIST"}]
}),
deleteUser: build.mutation<void, number>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE',
}),
invalidatesTags: [{type: "User", id: "LIST"}],
}),
}),
overrideExisting: true,
})
export const {
useGetUsersQuery, useGetUserQuery,
useCreateUserMutation, useDeleteUserMutation, useUpdateUserMutation
} = UsersService;
5. UI Side
We implemented a traditional list and swipeable detail pages in the UI using MUI5 components.
The User list page 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, {useState} from 'react';
import {
Alert,
Box,
Button,
ButtonGroup,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Snackbar,
SwipeableDrawer,
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TablePagination,
TableRow
} from "@mui/material";
import moment from "moment";
import {Delete, Edit, PersonAdd} from "@mui/icons-material";
import {useAppDispatch} from 'services/hooks';
import {useRouter} from "next/router";
import {NextPage} from "next";
import {useDeleteUserMutation, useGetUsersQuery} from "../../services/UserService";
import Footer from "../../components/Footer/Footer";
import UserDetail from "./components/UserDetail";
import {UserType} from "../../services/types/UserType";
import {clearUser, setUser} from "../../services/slices/UserSlice";
const EMPTY_DIALOG = {
open: false,
text: '',
title: '',
onConfirm: () => {
},
onCancel: () => {
}
}
const EMPTY_ALERT = {
open: false,
text: '',
};
const Users: NextPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(10);
const [dialog, setDialog] = useState(EMPTY_DIALOG);
const [alert, setAlert] = useState(EMPTY_ALERT);
const {
data,
error,
isLoading: isUsersLoading,
isSuccess: isUsersQueried,
isFetching: isUsersFetching,
isError: isUsersError
} = useGetUsersQuery({offset: (offset * limit), limit});
const [deleteUser, {
data: deletedUser,
isLoading: isUserDeleting,
isSuccess: isUserDeleted
}] = useDeleteUserMutation();
const drawerBleeding = 56;
const [openDrawer, setOpenDrawer] = React.useState(false);
const handleChangeRowsPerPage = ({target: {value}}) => {
setLimit(value);
};
const handleChangePage = (_, nextPage) => {
setOffset(nextPage);
};
const handleDeleteUser = (userId: number) => async () => {
try {
await deleteUser(userId).unwrap();
setAlert({
open: true,
text: `Successfully deleted user: ${userId}`,
});
resetDeleteDialog();
} catch (error) {
console.log(`Error: Failed deleting user with id ${userId}`);
}
};
const resetDeleteDialog = () => {
setDialog(EMPTY_DIALOG);
}
const openDeleteDialog = (userId: number) => () => {
setDialog({
open: true,
title: 'Delete user',
text: `Delete user: ${userId}?`,
onConfirm: handleDeleteUser(userId),
onCancel: () => resetDeleteDialog()
});
}
const resetAlert = () => {
setAlert(EMPTY_ALERT);
}
const editUser = (user: UserType) => () => {
setOpenDrawer(true);
dispatch(setUser(user));
};
const toggleEditDrawer = (newOpen: boolean) => () => {
if (!newOpen) {
dispatch(clearUser());
}
setOpenDrawer(newOpen);
};
const renderTable = (users: UserType[], count: number) => {
const hasUsers = count > 0;
return (
<React.Fragment>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell colSpan={6} align="right">
<Button variant="outlined" color="primary" onClick={toggleEditDrawer(true)}>
<PersonAdd/>
</Button>
</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={openDeleteDialog(user.id)}>
{<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>
<SwipeableDrawer
anchor="bottom"
open={openDrawer}
onClose={toggleEditDrawer(false)}
onOpen={toggleEditDrawer(true)}
swipeAreaWidth={drawerBleeding}
disableSwipeToOpen={false}
ModalProps={{
keepMounted: true,
}}
>
<UserDetail toggleEditDrawer={toggleEditDrawer}></UserDetail>
</SwipeableDrawer>
</React.Fragment>
);
}
const renderBody = () => {
if (isUsersQueried) {
const {users, count} = data;
return (isUsersFetching || isUsersLoading) ?
<Box sx={{display: 'flex'}}>
<CircularProgress/>
</Box> :
renderTable(users, count)
}
}
const renderError = () => {
return isUsersError && <Alert severity="error">{JSON.stringify(error)}</Alert>;
}
return (
<Container maxWidth={"md"} fixed>
{renderError()}
{renderBody()}
<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. This is triggered when we submit the form.
This is very important. Notice that inside the useEffect we are setting the form values in edit mode when the user is using formik. This useEffect will only be triggered if the value of the user changes to avoid too many re-renders errors.
6. Git Repository
As always, the complete source code is available on GitHub: https://github.com/czetsuya/nextjs-redux-toolkit.
Top comments (0)