Table of contents
We aim to create a minimal Full Stack application using the MERN stack. The app is a trello like collaboration tool with workspaces and tasks. Users can create new workspaces and all members of the workspace can add/edit tasks.
Problem Statement: Our focus is to create a concurrent app. Since many users can work on the tool simultaneously, our app must render any updates to the View as they occur. This is our main goal which we achieve using socket.io broadcasts.
On a very high level, each add/edit API call also emits a socket message telling the server that an update action is called. In return, the server broadcasts a client refresh message which instructs the connected clients to update their respective views. You can read more on socket io terms like brodcast, emit etc. here.
Without further ado, let's dive in. If you'd like to jump directly to the codebase then scroll down to the Appendix section!
Backend :
1. Server directory structure:
As you can see I have created different folders for the Express app. These directories are -
- index.js: This is the heart of our express app. All declarations and imports reside in this file.
- Models: This folder contains the schema for various documents in our mongo database.
- Routes: Here we define all the API routes and import the API methods.
- Controllers: Here reside the API controllers. The core business logic for all our APIs.
- Middleware: Helper functions for some APIs.
Our major focus is on making concurrent requests so, let's look at the task routes and how we have implemented socket functions on our server-side. For a deeper dive into the complete codebase, you can visit the git repo (link in appendix ⬇️)
2. Different parts of the Tasks API:
Starting off, we'll define the schema for the task documents. This Model file will be the schema reference for all other parts of the task API.
const mongoose = require("mongoose");
const taskSchema = new mongoose.Schema({
title: String,
content: String,
workspace: mongoose.Schema.Types.ObjectId,
},{collection:'task'});
module.exports = mongoose.model("task", taskSchema);
Now that we have our schema defined let's define the routes for the task API.
const express = require('express');
const taskControllers = require('../Controllers/task.controllers');
const router = express.Router();
router.post('/fetchTask', taskControllers.fetchTask);
router.post('/addTask', taskControllers.addTask);
router.post('/delTask', taskControllers.delTask);
module.exports = router
In the task routes file, we define all the task-related APIs. As shown in the code snippet, we have added /fetchTask, /addTask and /delTask routes and imported the same from the controller.
Finally, coming to the Task controller, here we define and export the API methods which were linked to specific API endpoints in the routes file.
const Task = require("../Models/task");
const mongoose = require("mongoose");
const fetchTask=async(req,res) =>{
const id = mongoose.Types.ObjectId(req.body.ws_id);
console.log(id)
await Task.find({ workspace:id}).then(tasks => {
if (!tasks) {
return res.status(200).json({ taskList: [] });
}
else{
return res.status(200).json({taskList:tasks});
}
});
}
const delTask=async(req,res) =>{
await Task.deleteOne({ _id: req.body.id })
.then(item=> res.status(200).json(item))
.catch(err=>res.status(500).json(err));
}
const addTask=async(req,res) =>{
const newTask = new Task({
title:req.body.title,
content:req.body.content,
workspace: mongoose.Types.ObjectId(req.body.ws_id)
});
if(req.body.id=="")
{
newTask.save()
.then(item => res.json(item))
.catch(err => console.log(err));
}
else
{
await Task.findById(mongoose.Types.ObjectId(req.body.id), function (err, doc) {
if (err) res.status(500).json(err);
doc.title = req.body.title;
doc.content = req.body.content;
doc.save()
.then(item => res.json(item))
.catch(err => console.log(err));
});
}
}
module.exports = { fetchTask,addTask, delTask };
Now that we have defined all the parts for the task API, lets add the task endpoint to the index.js file.
.
.
.
//imports
var taskRouter = require('./Routes/task.routes');
app.use('/task', taskRouter);
.
.
.
server.listen(port,()=>console.log("Server started on port"+port));
3. Setting up socket.io in our Express app:
To setup socket on the server-side, first install the socket library from npm.
npm i socket.io
Then make the following changes to your index.js file -
- Create an http server object using the express app object.
- Instantiate the socket io object with the server object.
- Set cors for the socket object.
- Remove app.listen() method and replace it with server.listen() so that both socket and express server listen for connections on the same port.
- Define the socket on connect method.
var app = express();
const server=require("http").Server(app);
const io = require("socket.io")(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
// remove app.listen(8080) and replace with ⬇️
server.listen(8080,() => console.log("http server start"));
io.on('connection', (socket) => {
/* socket object may be used to send specific messages to the new connected client */
console.log('new client connected');
});
4. Setting up client refresh broadcast:
Now that we have configured socket on server side and our socket server has started listening for connections, we can define out socket listener and broadcast method.
Update the socket connection method to add:
- socket.on(refresh task): This is a socket listener, it listens for the refresh client message which would be sent from the client. Whenever an add/edit method is called this would be called.
- When refresh_task socket is called our server will broadcast client refresh message to all sockets, instructing them to refresh their respective views.
io.on('connection', (socket) => { /* socket object may be used to send specific messages to the new connected client */
console.log('new client connected');
socket.on("refresh_task",(id)=>{
io.emit("client_refresh");
console.log("client_refresh_called");
});
});
Note: An important thing to note here - socket.broadcast.emit broadcasts to every client except the caller socket whereas io.emit broadcasts to all sockets including the caller.
At this point, we have configured our server to receive socket connections and broadcast client refresh message to update the view for all active sockets. Now let's move on and ree hot to set up the frontend using react Hooks.
Front-end:
For the front-end app, we are using react Hooks. You should have an understanding of useEffect and useState hooks to understand completely how the front end works and refreshes the view.
Here is a view of the app.js file where routing is setup.
import "./styles.css";
import { BrowserRouter as Router, Route } from "react-router-dom";
import Navbar from "./components/Layout/navbar";
import Landing from "./components/Layout/landing";
import Register from "./components/Auth/register";
import Login from "./components/Auth/login";
import Dash from "./components/Dashboard/dash";
import Workspace from "./components/Workspace/workspace";
import CreateWs from "./components/Dashboard/createWs";
export default function App() {
return (
<Router>
<Navbar />
<div className="App">
<Route exact path="/" component={Landing} />
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<Route exact path="/dashboard" component={Dash} />
<Route exact path="/workspace" component={Workspace} />
<Route exact path="/createWs" component={CreateWs} />
</div>
</Router>
);
}
In our components, I'm using axios for https requests to our server. Currently, the server is hosted as a repl on repl.it. So the tasks are visible on the Workspace component so lets deep dive on the component file- workspace.js. This is the Team workspace.
All the tasks for a specific team are listed in this workspace. The workspace members can edit, delete and add new tasks.
1. HTML structure for the workspace component:
The HTML structure is quite straightforward, we get the workspace name and render the tasklist as an array with their respective edit, delete buttons. The Add task, edit task form opens in a modal and then calls the api to load task for this specific workspace.
.
.
.
// imports
export default function Workspace(props) {
.
.
.
// component internal methods
return (
<div className="Workspace">
<div className="workspaceHead">{ws_name}</div>
<div className="taskList">
{taskList.map((item) => (
<div className="task" key={item.title}>
<div className="icon_bar">
<span
onClick={(e) =>
handleEdit(e, item._id, item.title, item.content)
}
>
Edit
</span>
<span onClick={(e) => delTask(e, item._id)}>X</span>
</div>
<div className="taskHead">{item.title}</div>
<div className="taskContent">{item.content}</div>
</div>
))}
</div>
<button
className="btn blue accent-3 waves-effect"
onClick={handleClickOpen}
>
Add Task
</button>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Task</DialogTitle>
<DialogContent>
<input
autoFocus
margin="dense"
id="title"
placeholder="Title"
type="text"
onChange={(e) => handleChange(e)}
value={task.title}
/>
<input
margin="dense"
id="content"
placeholder="Task Description"
type="text"
onChange={(e) => handleChange(e)}
value={task.content}
/>
</DialogContent>
<DialogActions>
<button
className="btn blue accent-3 waves-effect"
onClick={handleClose}
>
Cancel
</button>
<button
className="btn blue accent-3 waves-effect"
onClick={submitTask}
>
Submit
</button>
</DialogActions>
</Dialog>
</div>
);
}
2. Initial data fetch on component load:
To load the initial task list, we'll use the useEffect() Hook with an empty dependency list. The use effect hook is executed every time there is a change in the variables present in the dependency list. Since the dependency list is empty, it'll be called only once when the component mounts for the first time.
useEffect(() => {
var body = { ws_id: ws_id };
axios
.post("https://collabserver.shangkaul.repl.co/task/fetchTask", body)
.then((response) => {
setTaskList(response.data.taskList);
})
.catch((err) => {
console.log(err);
});
}, []);
As you can see, the endpoint is called with a payload of workspace ID to fetch all tasks for the said workspace ID.
Similarly, we'll add methods for add task, delete task and edit task which would call their own API endpoints to make changes to the tasks of this workspace.
3. Configuring client-side socket connection on React:
Install the socket client lib on react. Make sure the version is same as the socket library you installed on your server.
npm i socket.io-client@version
For connecting to socket I have made a separate socket service that connects to our socket server. We can directly import the socket object from this service to any component where we want to use it.
import io from "socket.io-client";
const ENDPOINT = "https://collabserver.shangkaul.repl.co";
export const socket = io(ENDPOINT);
4. Adding socket methods to our Workspace component:
Now to implement the promised concurrency in our front end, we'll have to send refresh_task message with each task change and ensure the view is updated with every change. We'll implement this in a step by step manner-
- Add socket.emit(refresh_task) to the submit and delete functions so that our server gets to know when these methods are called and triggers a client_refresh broadcast in return.
- On our client we need to listen to client_refresh message from the socket server.
- On recieving the client_refresh message, our view needs to update - we'll achieve this by leveraging the use Effect Hook.
- As mentioned in point 2, useEffect is triggered on every change in the dependency list variables, so we will create a state variable that gets updated every time the refresh client message is sent by the socket server. By adding this state variable to the useEffect Hook, we'll refresh our Task list every time there is any change in the task list.
//refresh state variable
const [refreshCount, setRefreshCount] = useState(0);
socket.on("client_refresh", (x) => {/* 2. Listening to clien_refresh msg from server and updating state*/
setRefreshCount(refreshCount + 1);
console.log("refresh called");
});
function submitTask(){
.
.
.
.
socket.emit("refresh_task",ws_id); // 1.Sending refresh task message to server
}
useEffect(() => {
var body = { ws_id: ws_id };
axios
.post("https://collabserver.shangkaul.repl.co/task/fetchTask", body)
.then((response) => {
setTaskList(response.data.taskList);
})
.catch((err) => {
console.log(err);
});
}, [refreshCount]); //Update UseEffect dependency list so task array is refreshed with every update
In this way, we have made a concurrent system wherein any updates to the tasks are reflected on the View in real-time.
Demo
The project is hosted on netlify you can try to check for yourself by testing it on multiple devices if the view gets updated in real-time!
The project is live on : superteam-collab.netlify.app
Please make sure that the backend server is running on replit
Finally, if you made it this far, a big thank you and do drop a like! Keep Building!🛠