Photo by Arnold Francisca on Unsplash
Getting started with Full Stack Web Development - MERN Stack
Part 3 - React and Redux Creating a functional TODO APP
In the last part of this tutorial, we finished implementing all of our backend required to start implementing the frontend.
In this part of the tutorial, we will start with our frontend, we start by running npx create-react-app frontend --template redux
in our root folder. This will help us set up our frontend folder using redux.
NOTE: This tutorial will not cover the styling aspect of this app, you can get the stylesheet used from here, add the style
folder to src
folder
Also, in our frontend
folder especially the src
folder, we want to clean up some stuff redux provided for us
- Delete everything inside the
features
folder in - Delete
App.css
,index.css
andlogo.svg
- In
store.js
located in theapp
folder, delete the counterReducer and the corresponding import. - We will also install some packages that will be used by our frontend, in the
frontend
folder runnpm install axios react-router-dom react-spinners react-toastify react-tsparticles sass tsparticles
Interacting with our backend from the frontend using react-redux and redux toolkit
We install a package in our root folder as a dev dependency since we are only using it in development, that allows us to run both the frontend and backend simultaneously, run npm install -D concurrently
.
In package.json
located in our root folder, in the scripts object, add
"client": "npm start --prefix frontend",
"dev": "concurrently \"npm run start\" \"npm run client\""
In the root folder, run npm run dev
to start both the frontend and backend server
You should see something similar to the above, to show that concurrently has started running.
- In
features
folder, create a new folderauth
, this folder will handle everything related to user sign-in, sign up and logout. - Create a new file
authService.js
, this file will contain the main interaction with our backend server, before will get started with this file, inpackage.json
located infrontend
folder, add this which will allow all requests from the frontend use the proxy URL as the root URL(the server on which our backend is running)"proxy": "http://localhost:5000"
In authService.js
,
import axios from 'axios';
const API_URL = '/api/user/';
// because of the proxy added in package.json, it gets interpreted to http://localhost:5000/api/user
const registerUser = async (userData) => {
const response = await axios.post(API_URL, userData)
if (response.data) {
localStorage.setItem('user', JSON.stringify(response.data))
}
return response.data
}
const loginUser = async (userData) => {
const response = await axios.post(API_URL + 'login', userData)
if (response.data) {
localStorage.setItem('user', JSON.stringify(response.data))
}
return response.data
}
const logout = () => {
localStorage.removeItem('user')
}
- Create a new file
authSice.js
inauth
folder, this file will handle state changes using redux
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
import authService from "./authService";
const user = JSON.parse(localStorage.getItem('user'))
// We get the stored user from the localStorage
const initialState = {
user: user ? user : null,
isLoading: false,
isSuccess: false,
isError: false,
message: ''
}
// We define the initial state of our app for `auth`
// Register user
export const register = createAsyncThunk('auth/register', async (user, thunkAPI) => {
try {
return await authService.registerUser(user)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
// Login User
export const login = createAsyncThunk('auth/login',async (userData, thunkAPI) => {
try {
return await authService.loginUser(userData)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
export const logout = createAsyncThunk('auth/logout', () => {
authService.logout()
})
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
reset: (state) => {
state.isLoading = false
state.isError = false
state.message = ''
state.isSuccess = false
}
},
extraReducers: (builder) => {
builder
.addCase(register.pending, state => {
state.isLoading = true
})
.addCase(register.fulfilled, (state, action) => {
state.isLoading = false
state.isSuccess = true
state.user = action.payload
})
.addCase(register.rejected, (state, action) => {
state.isLoading = false
state.isError = true
state.message = action.payload
})
.addCase(login.pending, (state) => {
state.isLoading = true
})
.addCase(login.fulfilled, (state, action) => {
state.isSuccess = true
state.isLoading = false
state.user = action.payload
})
.addCase(login.rejected, (state, action) => {
state.isError = true
state.isLoading = false
state.message = action.payload
})
.addCase(logout.fulfilled, (state) => {
state.user = null
})
}
})
/* The above are some redux actions on the functions defined,
the extraReducers have three methods, `pending, fulfilled and rejected`,
the value of the states are defined based on the three actions executed by redux
*/
export const { reset } = authSlice.actions
export default authSlice.reducer
We then import this authSlice.reducer
as authReducer
into store.js
in app
folder
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '../features/auth/authSlice';
export default configureStore({
reducer: {
auth: authReducer
}
})
- We follow the same approach as above for actions on todo, create a new folder
todos
infeatures
folder, create a new filetodoService.js
import axios from 'axios'
const API_URL = '/api/todos/'
// Create Todo
const createTodo = async (newTask, token) => {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
}
/* The config object contains the token that will be passed since this
route is protected from our backend, the same applies to all other routes too.
*/
const response = await axios.post(API_URL, newTask, config)
return response.data
}
// Get all Todos
const getAllTodos = async (token) => {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
}
const response = await axios.get(API_URL, config)
return response.data
}
const deleteTodo = async (id, token) => {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
}
const response = await axios.delete(API_URL + id, config)
return response.data
}
const updateTodo = async (id, newData, token) => {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
}
const response = await axios.put(API_URL + id, newData, config)
return response.data
}
const todoService = {
createTodo,
getAllTodos,
deleteTodo,
updateTodo
}
export default todoService
- Create a new file
todoSlice.js
intodos
folder
import { createAsyncThunk, createSlice, createSelector } from "@reduxjs/toolkit";
import todoService from "./todoService";
const initialState = {
todos: [],
isFiltering: false,
isLoading: false,
isSuccess: false,
isError: false,
message: ''
}
export const getTodos = createAsyncThunk('todo/getAll', async(_, thunkAPI) => {
try {
const token = thunkAPI.getState().auth.user.token
// token is extracted from the saved user in our auth state, the same applies to all other actions
return await todoService.getAllTodos(token)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
export const createTodo = createAsyncThunk('todo/createNew', async(newTask, thunkAPI) => {
try {
const token = thunkAPI.getState().auth.user.token
return await todoService.createTodo(newTask, token)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
export const deleteTodo = createAsyncThunk('todo/delete', async (id, thunkAPI) => {
try {
const token = thunkAPI.getState().auth.user.token
return await todoService.deleteTodo(id, token)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
export const updateTodo = createAsyncThunk('todo/update', async ({id, newData}, thunkAPI) => {
try {
const token = thunkAPI.getState().auth.user.token
return await todoService.updateTodo(id, newData, token)
} catch (error) {
const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString()
return thunkAPI.rejectWithValue(message)
}
})
export const todoSlice = createSlice({
name: 'todo',
initialState,
reducers: {
resetTodo: (state) => {
state.isLoading = false
state.isSuccess = false
state.message = ''
state.isError = false
}
},
extraReducers: builder => {
builder
.addCase(getTodos.pending, (state) => {
state.isLoading = true
})
.addCase(getTodos.fulfilled, (state, action) => {
state.isLoading = false
state.isSuccess = true
state.todos = action.payload
})
.addCase(getTodos.rejected, (state, action) => {
state.isLoading= false
state.isError = true
state.message = action.payload
})
.addCase(createTodo.pending, state => {
state.isLoading = true
})
.addCase(createTodo.fulfilled, (state, action) => {
state.isLoading = false
state.isSuccess = true
state.todos = [...state.todos, action.payload]
})
.addCase(createTodo.rejected, (state, action) => {
state.isLoading = false
state.isError = true
state.message = action.payload
})
.addCase(deleteTodo.pending, (state) => {
state.isLoading = true
})
.addCase(deleteTodo.fulfilled, (state, action) => {
state.isLoading= false
state.isSuccess= true
state.todos = state.todos.filter(todo => todo._id !== action.payload.id)
})
.addCase(deleteTodo.rejected, (state, action) => {
state.isLoading = false
state.isError = true
state.message = action.payload
})
.addCase(updateTodo.pending, (state) => {
state.isLoading = true
})
.addCase(updateTodo.fulfilled, (state, action) => {
state.isLoading = false
state.isSuccess = true
state.todos = state.todos.map(todo => {
if (todo._id === action.payload._id) {
return action.payload
} else {
return todo
}
})
})
.addCase(updateTodo.rejected, (state, action) => {
state.isLoading = false
state.isError = true
state.message = action.payload
})
}
})
/* Since filtering will be implemented for todo to get active todos,
completed todos and number of active todos left, we will be implementing createSelector from redux
*/
const selectTodos = state => state.todos
export const getCompletedTodos = createSelector(selectTodos, todos => todos.filter(todo => todo.completed))
export const getActiveTodos = createSelector(selectTodos, todos => todos.filter(todo => !todo.completed))
export const getAllTodos = createSelector(selectTodos, todos => todos.map(todo => todo))
export const { resetTodo } = todoSlice.actions
export default todoSlice.reducer
We need to also import todoSlice.reducer
as todoReducer
into our store.js
import { configureStore } from '@reduxjs/toolkit'
import authReducer from '../features/auth/authSlice';
import todoReducer from '../features/todos/todoSlice';
export default configureStore({
reducer: {
auth: authReducer,
todo: todoReducer
}
})
Since all our state is defined, we can go ahead to implement this in our UI
Creating the user interface, experience and design of our frontend
DON'T GIVE UP NOW, IT GETS INTERESTING FROM HERE...
Our UI is gotten from a challenge in frontend mentor
In App.js
, we define the routes of our app
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Register from './pages/Register';
import Index from './pages/Index';
import UserHomePage from './pages/UserHomePage';
import './styles/globals.scss';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'
function App() {
return (
<>
<Router>
<Routes>
<Route path='/signin' element={<Index />}/>
<Route path='/register' element={<Register />} />
<Route path='/' element={<UserHomePage />} />
</Routes>
</Router>
<ToastContainer />
</>
);
}
export default App;
- Create a new folder in
src
calledpages
andcomponents
, create a new file inpages
,index.jsx
which will point to the/signin
route as defined inApp.js
.
import ParticleLoader from "../components/ParticleLoader";
import SignInForm from "../components/SignInForm";
import style from '../styles/pages/index.module.scss';
function Index() {
return (
<>
<div className={style.main}>
<ParticleLoader />
<div>
<h1>Welcome to Todo</h1>
<SignInForm />
</div>
</div>
</>
)
}
export default Index
- In
components
folder, create a new fileParticleLoader.jsx
import Particles from 'react-tsparticles';
import { loadFull } from 'tsparticles';
function ParticleLoader() {
const particlesInit = async(main) => {
await loadFull(main)
}
const particlesLoaded = (container) => {
}
return (
<Particles
id="tsparticles"
init={particlesInit}
loaded={particlesLoaded}
options={{
fpsLimit: 60,
interactivity: {
events: {
resize: true
},
modes: {
push: {
quantity: 4
},
attract: {
distance: 200,
duration: 0.4,
factor: 5
}
}
},
particles:{
color: {
value: '#ffffff'
},
line_linked: {
color: '#ffffff',
distance: 150,
enable: true,
opacity: 0.4,
width: 1
},
move: {
attract: {
enable: false,
rotateX: 500,
rotateY: 1000
},
bounce: false,
direction: 'none',
enable: true,
out_model: "out",
random: false,
speed: 1.5,
straight: false
},
number: {
density: {
enable: true,
value_area: 800
},
value: 80
},
opacity: {
anim: {
enable: false,
opacity_min: 0.1,
speed: 1,
sync: false
},
random: false,
value: 0.5
},
}
}
}
/>
)
}
export default ParticleLoader
We will create a Spinner component Spinner.jsx
,
import ClipLoader from 'react-spinners/ClipLoader';
const override = {
display: "flex",
justifyContent: "center",
alignItems: "center",
margin: "auto",
borderColor: "black"
};
function Spinner() {
return (
<ClipLoader
color='#fff'
loading={true}
cssOverride={override}
size={150}
/>
)
}
export default Spinner
For our SIgnInForm
component, create a new file in compoents
folder SignInForm.jsx
import style from '../styles/components/form.module.scss';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { reset, login } from '../features/auth/authSlice'
import { useNavigate }from 'react-router-dom'
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import Spinner from './Spinner';
function SignInForm() {
const { user, isSuccess, isError, message, isLoading } = useSelector(state => state.auth);
// This will bring in the state from our store
const navigate = useNavigate()
const dispatch = useDispatch()
const [formData, setFormData] = useState({
email: '',
password: ''
})
const onChange = (e) => {
setFormData(prevState => (
{
...prevState,
[e.target.name]: e.target.value
}
))
}
useEffect(() => {
if (isError) {
toast.error(message)
}
if ((user && user.name) && isSuccess) {
navigate('/')
}
dispatch(reset())
}, [isError, message, isSuccess, navigate, user, dispatch])
const signin = (e) => {
e.preventDefault()
dispatch(login(formData))
// This executes the login action defined from authSlice using the formData
}
if (isLoading) {
return <Spinner />
}
return (
<div className={style.formContainer}>
<main>
<form onSubmit={signin}>
<h2>Sign In</h2>
<div>
<label htmlFor="email">Email</label>
<input type="email" name="email" onChange={onChange}/>
</div>
<div>
<label htmlFor="password">Password</label>
<input type="password" name="password" onChange={onChange}/>
</div>
<div>
<input type="submit" value="Sign In" className={style.signin}/>
</div>
<div className={style.register}>
<Link to='/register'>
Register
</Link>
</div>
</form>
</main>
</div>
)
}
export default SignInForm
In the pages
folder, create a new file Register.jsx
,
import ParticleLoader from "../components/ParticleLoader"
import RegisterForm from "../components/RegisterForm";
import style from '../styles/pages/index.module.scss';
function Register() {
return (
<div className={style.main}>
<ParticleLoader />
<RegisterForm />
</div>
)
}
export default Register
- Create the RegisterForm component by creating
RegisterForm.jsx
in thecomponents
folder
import { Link } from 'react-router-dom';
import style from '../styles/components/form.module.scss';
import {toast} from 'react-toastify';
import { useSelector, useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { register, reset } from '../features/auth/authSlice';
import { useEffect } from 'react';
import Spinner from './Spinner';
function Register() {
const dispatch = useDispatch()
const navigate = useNavigate()
const { user, isLoading, isError, isSuccess, message } = useSelector(state => state.auth)
// This gets the state from store.js
useEffect(() => {
if (isError) {
toast.error(message)
}
if (user || isSuccess) {
navigate('/')
}
dispatch(reset())
}, [user, isError, isSuccess, message, navigate, dispatch])
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
password2: ''
})
const { name, email, password, password2 } = formData;
const onChange = (e) => {
setFormData((prevState) => ({
...prevState,
[e.target.name]: e.target.value
})
)}
const onSubmit = (e) => {
e.preventDefault()
if (password !== password2) {
toast.error('Password do not match')
}
else {
const userData = {
name,
email,
password
}
dispatch(register(userData))
// This executes the register action defined in authSlice.js
}
}
if (isLoading) {
return <Spinner />
}
return (
<div className={style.formContainer}>
<main>
<form onSubmit={onSubmit}>
<h2>Register</h2>
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" onChange={onChange}/>
</div>
<div>
<label htmlFor="email">Email</label>
<input type="email" name="email" onChange={onChange}/>
</div>
<div>
<label htmlFor="password">Password</label>
<input type="password" name="password" onChange={onChange}/>
</div>
<div>
<label htmlFor="password">Enter Password Again</label>
<input type="password" name="password2" onChange={onChange}/>
</div>
<div>
<input type="submit" value="Register" className={style.signin}/>
</div>
<div className={style.register}>
<Link to='/signin'>
Signin
</Link>
</div>
</form>
</main>
</div>
)
}
export default Register
- In
pages
folder, create a new fileuserHomePage.jsx
import Background from "../components/Background"
import TodoContainer from "../components/TodoContainer"
import style from '../styles/pages/userHomePage.module.scss';
import { useSelector, useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom';
import { useEffect, useContext } from 'react';
import { reset } from '../features/auth/authSlice';
import Spinner from "../components/Spinner";
import { getTodos , resetTodo } from '../features/todos/todoSlice';
import { toast } from 'react-toastify';
function UserHomePage() {
const dispatch = useDispatch()
const navigate = useNavigate()
const { user, isError, isLoading , message} = useSelector(state => state.auth);
useEffect(() => {
if (!user) {
navigate('/signin')
}
if (user && user.name) {
dispatch(getTodos())
}
if (isError) {
toast.error(message)
}
dispatch(reset())
dispatch(resetTodo())
}, [user, dispatch, navigate, isError, message])
if (isLoading || !user) {
return <Spinner />
}
return (
<div className={style.body}>
<Background />
<TodoContainer />
</div>
)
}
export default UserHomePage
- Create the Background component
Background.jsx
import style from '../styles/components/background.module.scss';
import { logout } from '../features/auth/authSlice';
import { useDispatch, useSelector } from 'react-redux'
function Background() {
const dispatch = useDispatch()
const { user } = useSelector(state => state.auth)
return (
<>
<div className={style.image}>
<img src="/images/bg-desktop-light.jpg" alt="" />
</div>
<div className={style.userDetails}>
<h2>Welcome {user.name}</h2>
<p onClick={() => dispatch(logout())}>Logout</p>
</div>
</>
)
}
export default Background
- Create the TodoContainer component
TodoContainer.jsx
import Logo from './Logo'
import style from '../styles/components/todoContainer.module.scss';
import TodoLists from './TodoLists';
import { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { createTodo } from '../features/todos/todoSlice'
function TodoContainer() {
const task = useRef(null)
const dispatch = useDispatch()
const handleNewTask = (e) => {
if (e.key === 'Enter') {
dispatch(createTodo({task: task.current.value}))
task.current.value = ''
}
}
return (
<div className={style.container}>
<div className={style.logo}>
<Logo />
<img src='/images/icon-moon.svg' alt="theme-mode" />
</div>
<div className={`${style.todo} ${style.inputTodo}`}>
<div className={style.roundCheckbox}>
<input type="checkbox" id='check' checked={false} readOnly/>
<label htmlFor="check"></label>
</div>
<input type="text" name="newTask" ref={task} onKeyDown={handleNewTask} placeholder='Create a new todo...'/>
</div>
<TodoLists />
</div>
)
}
export default TodoContainer
- Create Logo Component
Logo.jsx
import style from '../styles/components/logo.module.scss';
function Logo() {
return (
<h1 className={style.logo}>
TODO
</h1>
)
}
export default Logo
- Create the TodoLists components,
TodoLists.jsx
import Todo from './Todo';
import TodoFooter from './TodoFooter';
import style from '../styles/components/todoList.module.scss';
import ActionList from './ActionList';
import { useSelector } from 'react-redux';
import { useState } from 'react';
import { getCompletedTodos, getActiveTodos, getAllTodos } from '../features/todos/todoSlice';
function TodoLists() {
const [filter, setFilter] = useState('all');
const todos = useSelector(state => {
if (filter === 'all') {
return getAllTodos(state.todo)
} else if (filter === 'active') {
return getActiveTodos(state.todo)
} else if (filter === 'completed') {
return getCompletedTodos(state.todo)
}
})
return (
<>
<div className={style.container}>
{todos.map(todo => <Todo task={todo.task} key={todo._id} id={todo._id} completed={todo.completed}/>)}
<TodoFooter setFilter={setFilter} filter={filter}/>
</div>
<ActionList setFilter={setFilter} filter={filter}/>
</>
)
}
export default TodoLists
- Create TodoFooter component,
TodoFooter.jsx
,
import style from '../styles/components/todoFooter.module.scss';
import ActionList from './ActionList';
import { useSelector, useDispatch } from 'react-redux';
import {getActiveTodos, getCompletedTodos, deleteTodo} from '../features/todos/todoSlice';
function TodoFooter({setFilter, filter}) {
const dispatch = useDispatch()
const activeTodoLeft = useSelector(state => getActiveTodos(state.todo)).length
const completedTodo = useSelector(state => getCompletedTodos(state.todo))
const handleClearCompleted = () => {
completedTodo.forEach(todo => dispatch(deleteTodo(todo._id)))
}
return (
<div className={style.footer}>
<p>{activeTodoLeft} items left</p>
<div className={style.displayActionList}>
<ActionList setFilter={setFilter} filter={filter}/>
</div>
<p onClick={handleClearCompleted}>Clear Completed</p>
</div>
)
}
export default TodoFooter
- Create ActionList component
`ActionList.jsx
import style from '../styles/components/todoFooter.module.scss';
function ActionList({filter, setFilter}) {
return (
<div className={`${style.action} ${style[theme]}`}>
<p onClick={() => setFilter('all')} className={filter === 'all' ? style.active : ''}>All</p>
<p onClick={() => setFilter('active')} className={filter === 'active' ? style.active : ''}>Active</p>
<p onClick={() => setFilter('completed')} className={filter === 'completed' ? style.active : ''}>Completed</p>
</div>
)
}
export default ActionList
- Create Todo component,
Todo.jsx
import todoStyle from '../styles/components/todoContainer.module.scss';
import { deleteTodo, updateTodo } from '../features/todos/todoSlice';
import { useDispatch } from 'react-redux'
function Todo({task, id, completed}) {
const dispatch = useDispatch()
const handleCompleted = () => {
dispatch(updateTodo({id, newData: {completed: !completed}}))
}
return (
<div className={todoStyle.todo}>
<div className={todoStyle.roundCheckbox}>
<input type="checkbox" id={id} checked={completed} onChange={handleCompleted}/>
<label htmlFor={id}></label>
</div>
<p className={completed ? todoStyle.checked : ''}>{task}</p>
<div onClick={() => dispatch(deleteTodo(id))} className={todoStyle.delete}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><path fill="#494C6B" fillRule="evenodd" d="M16.97 0l.708.707L9.546 8.84l8.132 8.132-.707.707-8.132-8.132-8.132 8.132L0 16.97l8.132-8.132L0 .707.707 0 8.84 8.132 16.971 0z"/></svg>
</div>
</div>
)
}
export default Todo
Kudos for making it this far, we are finally done with our app.
The final step is deployment so that the world can see what we have built.
Click here to access part 4
All Codes can be found on here