DEV Community

Andrey Smirnov
Andrey Smirnov

Posted on • Updated on

Sending messages to clients in real-time using Server-sent Events, NodeJS and React

Overview

Server-Sent Events(SSE) technology allows sending data from server to clients in real-time, it's based on HTTP.

On the client side server-sent events provides EventSource API (part of the HTML5 standard), that allows us to open a permanent connection to the HTTP server and receive messages(events) from it.

On the server side headers are required to keep the connection open. The Content-Type header set to text/event-stream and the Connection header set to keep-alive.

The connection remains open until closed by calling EventSource.close().

Restrictions:

  • Allows only receive events from server (unidirectional data flow, unlike WebSockets);
  • Events are limited to UTF-8 (no binary data).

Possible benefits:

  • Because SSE works via HTTP, it will be work on clients that use proxy, that not support other protocols (like WebSocket);
  • If connection use HTTPS then no need to think about traffic encryption.

Browsers support: https://caniuse.com/eventsource.

In this article we will develop Todo List app, that allow us add, delete, mark as done tasks in the list.

The state of the Todo List will be shared among all connected users via Server-Sent Events.

Image description

Step 1 - Building Express Backend

# Create and go to project directory
mkdir sse
cd sse

# Create and go to subdirectory of backend part of project
mkdir server
cd server

# Initialize project and install required dependencies
npm init -y
npm install express@^4.18.1 body-parser@^1.20.0 compression@^1.7.4 cors@^2.8.5 --save
Enter fullscreen mode Exit fullscreen mode

After installing required dependencies open package.json and add "type": "module" after project name. This is necessary so that NodeJS can work with javascript modules.

{
   "name": "server",
   "type": "module"
   ...
}
Enter fullscreen mode Exit fullscreen mode

Create file server.js and add some template code:

import express from 'express';
import compression from 'compression';
import bodyParser from 'body-parser';
import cors from 'cors';

const app = express();
app.use(compression());
app.use(cors());
app.use(bodyParser.json());

let clients = [];
let todoState = [];

app.get('/state', (req, res) => {
    res.json(todoState);
});

const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
   console.log(`Shared todo list server listening at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Starting the server with the command npm start. If everything is done correctly, then by making the request curl http://localhost:3005/state you will see [] - an empty list of todo sheet.
Next, before the port declaration const PORT = process.env.PART || 3005; add the code to connect the client via SSE:

app.get('/events', (req, res) => {
   const headers = {
      // The 'text/event-stream' connection type
      // is required for SSE
      'Content-Type': 'text/event-stream',
      'Access-Control-Allow-Origin': '*',
      // Setting the connection open 'keep-alive'
      'Connection': 'keep-alive',
      'Cache-Control': 'no-cache'
   };
   // Write successful response status 200 in the header
   res.writeHead(200, headers);

   /*
   Data Shaping:
   When the EventSource receives multiple consecutive
   lines that begin with data:, it concatenates them,
   inserting a newline character between each one.
   Trailing newlines are removed.
   Double trailing newline \n\n is mandatory to indicate
   the end of an event
   */
   const sendData = `data: ${JSON.stringify(todoState)}\n\n`;

   res.write(sendData);
   // If compression middleware is used, then res.flash()
   // must be added to send data to the user
   res.flush();

   // Creating a unique client ID
   const clientId = genUniqId();
   const newClient = {
      id: clientId,
      res,
   };

   clients.push(newClient);

   console.log(`${clientId} - Connection opened`);

   req.on('close', () => {
      console.log(`${clientId} - Connection closed`);
      clients = clients.filter(client => client.id !== clientId);
   });
});

function genUniqId(){
   return Date.now() + '-' + Math.floor(Math.random() * 1000000000);
}
Enter fullscreen mode Exit fullscreen mode

So, we wrote the code that allows the client to connect by establishing a permanent connection, and also saved the id and res in the array of clients so that in the future we could send data to connected clients.

To check that everything is working, we will add a code to transfer the unique ids of the connected users.

app.get('/clients', (req, res) => {
   res.json(clients.map((client) => client.id));
});
Enter fullscreen mode Exit fullscreen mode

Starting the server npm start.
We connect to the server in the new terminal:

curl -H Accept:text/event-stream http://localhost:3005/events
Enter fullscreen mode Exit fullscreen mode

In different terminals, you can repeat the command several times to simulate the connection of several clients.
Checking the list of connected:

curl http://localhost:3005/clients
Enter fullscreen mode Exit fullscreen mode

In the terminal, you should see an array of ids of connected clients:

["1652948725022-121572961","1652948939397-946425533"]
Enter fullscreen mode Exit fullscreen mode

Now let's start writing the business logic of the Todo List application, we need:
a) Add a task to the todo list;
b) Delete a task from the todo list;
c) Set/unset task completion;
d) After each action, send the state to all connected clients.

The todo list state will look like this:

[
   {
      id: "1652980545287-628967479",
      text: "Task 1",
      checked: true
   },
   {
      id: "1652980542043-2529066",
      text: "Task 2",
      checked: false
   },
   ...
]
Enter fullscreen mode Exit fullscreen mode

Where id is a unique identifier generated by the server, text is the text of the task, checked is the state of the task checkbox.

Let's start with d) - after each action, send the state to all connected clients:

function sendToAllUsers() {
   for(let i=0; i<clients.length; i++){
      clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`);
      clients[i].res.flush();
   }
}
Enter fullscreen mode Exit fullscreen mode

Then we implement a) b) and c):

// Add a new task to the list and
// send the state to all clients
app.post('/add-task', (req, res) => {
   const addedText = req.body.text;
   todoState = [
      { id: genUniqId(), text: addedText, checked: false },
      ...todoState
   ];
   res.json(null);
   sendToAllUsers();
});

// Change the state of the task in the list
// and send the result state to all clients
app.post('/check-task', (req, res) => {
   const id = req.body.id;
   const checked = req.body.checked;
   todoState = todoState.map((item) => {
      if(item.id === id){
         return { ...item, checked };
      }
      else{
         return item;
      }
   });
   res.json(null);
   sendToAllUsers();
});

// Remove the task from the list and
// send the new state of the list to all clients
app.post('/del-task', (req, res) => {
   const id = req.body.id;
   todoState = todoState.filter((item) => {
      return item.id !== id;
   });
   res.json(null);
   sendToAllUsers();
});
Enter fullscreen mode Exit fullscreen mode

So, the server part is ready. Full code of the server:

import express from 'express';
import compression from 'compression';
import bodyParser from 'body-parser';
import cors from 'cors';

const app = express();
app.use(compression());
app.use(cors());
app.use(bodyParser.json());

let clients = [];
let todoState = [];

app.get('/state', (req, res) => {
   res.json(todoState);
});

app.get('/events', (req, res) => {
   const headers = {
      'Content-Type': 'text/event-stream',
      'Access-Control-Allow-Origin': '*',
      'Connection': 'keep-alive',
      'Cache-Control': 'no-cache'
   };
   res.writeHead(200, headers);

   const sendData = `data: ${JSON.stringify(todoState)}\n\n`;
   res.write(sendData);
   res.flush();

   const clientId = genUniqId();

   const newClient = {
      id: clientId,
      res,
   };

   clients.push(newClient);

   console.log(`${clientId} - Connection opened`);

   req.on('close', () => {
      console.log(`${clientId} - Connection closed`);
      clients = clients.filter(client => client.id !== clientId);
   });
});

function genUniqId(){
   return Date.now() + '-' + Math.floor(Math.random() * 1000000000);
}

function sendToAllUsers() {
   for(let i=0; i<clients.length; i++){
      clients[i].res.write(`data: ${JSON.stringify(todoState)}\n\n`);
      clients[i].res.flush();
   }
}

app.get('/clients', (req, res) => {
   res.json(clients.map((client) => client.id));
});

app.post('/add-task', (req, res) => {
   const addedText = req.body.text;
   todoState = [
      { id: genUniqId(), text: addedText, checked: false },
      ...todoState
   ];
   res.json(null);
   sendToAllUsers();
});

app.post('/check-task', (req, res) => {
   const id = req.body.id;
   const checked = req.body.checked;

   todoState = todoState.map((item) => {
      if(item.id === id){
         return { ...item, checked };
      }
      else{
         return item;
      }
   });
   res.json(null);
   sendToAllUsers();
});

app.post('/del-task', (req, res) => {
   const id = req.body.id;
   todoState = todoState.filter((item) => {
      return item.id !== id;
   });

   res.json(null);
   sendToAllUsers();
});

const PORT = process.env.PORT || 3005;
app.listen(PORT, () => {
    console.log(`Shared todo list server listening at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Then proceed to the second step - the client part.

Step 2 - Building the Client part: React application

Go to the previously created project folder sse, then run the command to create the react application template:

npx create-react-app client
Enter fullscreen mode Exit fullscreen mode

Next, go to the folder of the created application and launch it:

cd client
npm start
Enter fullscreen mode Exit fullscreen mode

After that, the client application page should open in the browser http://localhost:3000.

Next, go to the file src/index.js and remove React.StrictMode from the application.

// Before
root.render(
   <React.StrictMode>
      <App />
   </React.StrictMode>
);
// After
root.render(
   <App />
);
Enter fullscreen mode Exit fullscreen mode

The fact is that React StrictMode renders components twice in development mode to detect possible problems. But in our case, this is not necessary, otherwise the client will connect to the server twice and establish a permanent connection.

Remove all the contents from the App.css file and insert our own styles:

h1 {
   text-align: center;
}
main {
   display: flex;
   justify-content: center;
}
.l-todo {
   max-width: 31.25rem;
}
form {
   margin-bottom: 1rem;
}
form input[type="submit"] {
    margin-left: 0.5rem;
}
.task-group {
   margin-bottom: 0.125rem;
   display: flex;
   flex-wrap: nowrap;
   justify-content: space-between;
}
.task-group button {
   padding: 0.25rem 0.5rem;
   margin-left: 0.5rem;
   border: none;
   background-color: white;
}
Enter fullscreen mode Exit fullscreen mode

Let's prepare the application template, remove from the App.js all content and insert our:

import './App.css';
import { useState, useEffect, useRef } from 'react';

function App(){
   return(
      <main>
      </main>
   );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's add a list state hook to our App component:

const [tasks, setTasks] = useState([]);
Enter fullscreen mode Exit fullscreen mode

Now let's add the useEffect hook in which we will establish a permanent SSE connection:

useEffect(() => {
   let mount = true;
   let events;
   let timer;
   let createEvents = () => {
      // Close connection if open
      if(events){
            events.close();
      }
      // Establishing an SSE connection
      events = new EventSource(`http://localhost:3005/events`);
      events.onmessage = (event) => {
            // If the component is mounted, we set the state
            // of the list with the received data
            if(mount){
               let parsedData = JSON.parse(event.data);
               setTasks(parsedData);
            }
      };
      // If an error occurs, we wait a second
      // and call the connection function again
      events.onerror = (err) => {
            timer = setTimeout(() => {
               createEvents();
            }, 1000);
      };
   };
   createEvents();

   // Before unmounting the component, we clean
   // the timer and close the connection
   return () => {
      mount = false;
      clearTimeout(timer);
      events.close();
   }
}, []);
Enter fullscreen mode Exit fullscreen mode

Now when opening the client site http://localhost:3000 a connection to the server will occur and the server will send the todo list state to the connected client. The client, after receiving the data, will set the state of the todo list.

Let's develop an interface component for adding a new task to the list.
Image description
Add a file to the project src/AddTask.js

function AddTask(props){
   const { text, onTextChange, onSubmit, textRef } = props;
   return(
      <form onSubmit={onSubmit}>
         <input
            type="text"
            name="add"
            value={text}
            onChange={onTextChange}
            ref={textRef}
         />
         <input
            type="submit"
            value="Add"
         />
      </form>
   );
}

export default AddTask;
Enter fullscreen mode Exit fullscreen mode

Creating a list item element:
Image description
Add a file to the project src/Task.js:

function Task(props){
   const { id, text, checked, onCheck, onDel } = props;
   return(
      <div className="task-group">
         <div>
            <input
               type="checkbox"
               name={`chk${id}`}
               id={`chk${id}`}
               checked={checked}
               onChange={onCheck}
            />
            <label htmlFor={`chk${id}`}>{text}</label>
         </div>
         <button
            id={`btn${id}`}
            onClick={onDel}>x
         </button>
      </div>
   );
}

export default Task;
Enter fullscreen mode Exit fullscreen mode

Include the created files to App.js:

import AddTask from './AddTask';
import Task from './Task';
Enter fullscreen mode Exit fullscreen mode

In our application, we will transmit data to the server in JSON format, so before moving on, we will write a small wrapper for the javascript fetch API to simplify the client code. Create a file /src/jsonFetch.js:

function jsonFetch(url, data){
   return new Promise(function(resolve, reject){
      fetch(url, {
         method: 'POST',
         headers: {
               'Content-Type': 'application/json'
         },
         body: JSON.stringify(data)
      })
      .then(function(res){
         if(res.ok){
               const contentType = res.headers.get('content-type');
               if(contentType && contentType.includes('application/json')){
                  return res.json();
               }
               return reject(`Not JSON, content-type: ${contentType}`);
         }
         return reject(`Status: ${res.status}`);
      })
      .then(function(res){
         resolve(res);
      })
      .catch(function(error){
         reject(error);
      });
   });
}

export default jsonFetch;
Enter fullscreen mode Exit fullscreen mode

Include created file in App.js:

import jsonFetch from './jsonFetch';
Enter fullscreen mode Exit fullscreen mode

Now let's add our addTask and Task components to the App component:

function App(){
   const [addTaskText, setAddTaskText] = useState('');
   const [tasks, setTasks] = useState([]);
   const addTextRef = useRef(null);

   useEffect(() => {
   // SSE code
      ...
   },[]);

   const tasksElements = tasks.map((item) => {
      return(
         <Task
            key={item.id}
            id={item.id}
            text={item.text}
            checked={item.checked}
            onCheck={handleTaskCheck}
            onDel={handleTaskDel}
         />
      );
   });

   return (
      <main>
         <div className="l-todo">
               <h1>Todo List</h1>
               <AddTask
                  text={addTaskText}
                  onSubmit={handleAddTaskSubmit}
                  onTextChange={handleAddTaskTextChange}
                  textRef={addTextRef}
               />
               {tasksElements}
         </div>
      </main>
   );
}
Enter fullscreen mode Exit fullscreen mode

Let's write user event handlers in the App component:

function handleAddTaskTextChange(event){
   setAddTaskText(event.target.value);
}

function handleAddTaskSubmit(event){
   event.preventDefault();
   let addedText = addTaskText.trim();
   if(!addedText){
      return setAddTaskText('');
   }
   jsonFetch('http://localhost:3005/add-task', {text: addedText})
   .then(() => {
      setAddTaskText('');
   })
   .catch((err) => {
      console.log(err);
   })
   .finally(() => {
      addTextRef.current.focus();
   });
}

function handleTaskCheck(event){
   const checked =  event.target.checked;
   const targetId = event.target.id.substring(3);

   jsonFetch('http://localhost:3005/check-task', {id: targetId, checked})
   .catch((err) => {
      console.log(err);
   });
}

function handleTaskDel(event){
   let targetId = event.target.id.substring(3);

   jsonFetch('http://localhost:3005/del-task', {id: targetId})
   .catch((err) => {
      console.log(err);
   });
}
Enter fullscreen mode Exit fullscreen mode

So, the logic of the client application: when component did mount, a SSE connection is created to the server, which transmits the state of the list when connected. After receiving the state of the list from the server, it is set to the client setTasks(parsedData).
Further, when adding, deleting and set/unset tasks, the changes are sending to the server, there they are recording to todoState and transmitting to all connected users.

Full client application code:

import './App.css';
import { useState, useEffect, useRef } from 'react';
import AddTask from './AddTask';
import Task from './Task';
import jsonFetch from './jsonFetch';

function App(){
   const [addTaskText, setAddTaskText] = useState('');
   const [tasks, setTasks] = useState([]);
   const addTextRef = useRef(null);

   useEffect(() => {
      let mount = true;
      let events;
      let timer;
      let createEvents = () => {
         if(events){
            events.close();
         }
         events = new EventSource(`http://localhost:3005/events`);
         events.onmessage = (event) => {
            if(mount){
               let parsedData = JSON.parse(event.data);
               setTasks(parsedData);
            }
         };
         events.onerror = (err) => {
            timer = setTimeout(() => {
               createEvents();
            }, 1000);
         };
      };
      createEvents();

      return () => {
         mount = false;
         clearTimeout(timer);
         events.close();
      }
   }, []);

   const tasksElements = tasks.map((item) => {
      return(
         <Task
            key={item.id}
            id={item.id}
            text={item.text}
            checked={item.checked}
            onCheck={handleTaskCheck}
            onDel={handleTaskDel}
         />
      );
   });

   return (
      <main>
         <div className="l-todo">
               <h1>Todo List</h1>
               <AddTask
                  text={addTaskText}
                  onSubmit={handleAddTaskSubmit}
                  onTextChange={handleAddTaskTextChange}
                  textRef={addTextRef}
               />
               {tasksElements}
         </div>
      </main>
   );

   function handleAddTaskTextChange(event){
      setAddTaskText(event.target.value);
   }

   function handleAddTaskSubmit(event){
      event.preventDefault();
      let addedText = addTaskText.trim();
      if(!addedText){
         return setAddTaskText('');
      }
      jsonFetch('http://localhost:3005/add-task', {text: addedText})
      .then(() => {
         setAddTaskText('');
      })
      .catch((err) => {
         console.log(err);
      })
      .finally(() => {
         addTextRef.current.focus();
      });
   }

   function handleTaskCheck(event){
      const checked =  event.target.checked;
      const targetId = event.target.id.substring(3);

      jsonFetch('http://localhost:3005/check-task', {id: targetId, checked})
      .catch((err) => {
         console.log(err);
      });
   }

   function handleTaskDel(event){
      let targetId = event.target.id.substring(3);

      jsonFetch('http://localhost:3005/del-task', {id: targetId})
      .catch((err) => {
         console.log(err);
      });
   }
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Please support me, like and write comments.

Top comments (1)

Collapse
 
nicolofranceschi profile image
Nicolofranceschi

Nice man