In this tutorial, we’ll go through how to build a photo feed with React and Cloudinary, while providing realtime updates to the feed using Pusher Channels. You can find the entire source code of the application in this GitHub repository.
To follow along, a basic knowledge of JavaScript (ES6) and React is required. You also need to have the following installed on your machine:
Let’s set up a simple Node server for the purpose of uploading images to Cloudinary and triggering realtime updates with Pusher Channels.
The first step is to create a new empty directory and run npm init -y
from within it. Next, install all the dependencies that we need for this project by running the command below:
npm install express nedb cors body-parser connect-multiparty pusher cloudinary dotenv
Wait for the installation to complete, then create a file named server.js
in the root of your project directory and populate the file with the following contents:
1// server.js 2 3 // import dependencies 4 require('dotenv').config({ path: 'variables.env' }); 5 const express = require('express'); 6 const multipart = require('connect-multiparty'); 7 const bodyParser = require('body-parser'); 8 const cloudinary = require('cloudinary'); 9 const cors = require('cors'); 10 const Datastore = require('nedb'); 11 const Pusher = require('pusher'); 12 13 // Create an express app 14 const app = express(); 15 // Create a database 16 const db = new Datastore(); 17 18 // Configure middlewares 19 app.use(cors()); 20 app.use(bodyParser.urlencoded({ extended: false })); 21 app.use(bodyParser.json()); 22 23 // Setup multiparty 24 const multipartMiddleware = multipart(); 25 26 app.set('port', process.env.PORT || 5000); 27 const server = app.listen(app.get('port'), () => { 28 console.log(`Express running → PORT ${server.address().port}`); 29 });
Here, we’ve imported the dependencies into our entry file. Here’s an explanation of what they all do:
.env
file into process.env
.Create a variables.env
file in the root of your project and add a PORT
variable therein:
1// variables.env 2 3 PORT:5000
Hard-coding credentials in your code is a bad practice so we’ve set up dotenv
to load the app’s credentials from variables.env
and make them available on process.env
.
To get started with Pusher Channels, sign up) for a free Pusher account. Then go to the dashboard and create a new Channels app.
Once your app is created, retrieve your credentials from the API Keys tab, then add the following to your variables.env
file:
1// variables.env 2 3 PUSHER_APP_ID=<your app id> 4 PUSHER_APP_KEY=<your app key> 5 PUSHER_APP_SECRET=<your app secret> 6 PUSHER_APP_CLUSTER=<your app cluster>
Next, initialize the Pusher SDK within server.js
:
1// server.js 2 ... 3 const db = new Datastore(); 4 5 const pusher = new Pusher({ 6 appId: process.env.PUSHER_APP_ID, 7 key: process.env.PUSHER_APP_KEY, 8 secret: process.env.PUSHER_APP_SECRET, 9 cluster: process.env.PUSHER_APP_CLUSTER, 10 encrypted: true, 11 }); 12 13 ...
Visit the Cloudinary website and sign up for a free account. Once your account is confirmed, retrieve your credentials from the dashboard, then add the following to your variables.env
file:
1// variables.env 2 3 CLOUDINARY_CLOUD_NAME=<your cloud name> 4 CLOUDINARY_API_KEY=<your api key> 5 CLOUDINARY_API_SECRET=<your api secret>
Next, initialize the Cloudinary SDK within server.js
under the pusher
variable:
1// server.js 2 3 cloudinary.config({ 4 cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 5 api_key: process.env.CLOUDINARY_API_KEY, 6 api_secret: process.env.CLOUDINARY_API_SECRET, 7 });
We are going to create two routes for our application: the first one will serve all gallery images, while the second one handles the addition of a new image to the database.
Here’s the one that handles sending all images to the client. Add this above the port
variable:
1// server.js 2 3 app.get('/', (req, res) => { 4 db.find({}, (err, data) => { 5 if (err) return res.status(500).send(err); 6 res.json(data); 7 }); 8 });
When this endpoint is hit, a JSON representation of all images that exist in the database will be sent to the client, except if an error is encountered, in which case a 500 server error will be sent instead.
Next, let’s add the route that adds new images sent from the client to the database.
1// server.js 2 3 app.post('/upload', multipartMiddleware, (req, res) => { 4 // Upload image 5 cloudinary.v2.uploader.upload(req.files.image.path, {}, function( 6 error, 7 result 8 ) { 9 if (error) { 10 return res.status(500).send(error); 11 } 12 // Save image to database 13 db.insert(Object.assign({}, result, req.body), (err, newDoc) => { 14 if (err) { 15 return res.status(500).send(err); 16 } 17 // 18 pusher.trigger('gallery', 'upload', { 19 image: newDoc, 20 }); 21 res.status(200).json(newDoc); 22 }); 23 }); 24 });
Here, the image is uploaded to Cloudinary and, on successful upload, a database entry is created for the image and a new upload
event is emitted for the gallery
channel along with the payload of the newly created item.
The code for the server is now complete. You can start it by running node server.js
in your terminal.
Let's bootstrap our project using the create-react-app which allows us to quickly get a React application up and running. Open a new terminal window, and run the following command to install create-react-app
on your machine:
npm install -g create-react-app
Once the installation process is done, you can run the command below to setup your react application:
create-react-app client
This command will create a new folder called client
in the root of your project directory, and install all the dependencies needed to build and run the React application.
Next, cd
into the newly created directory and install the other dependencies which we’ll be needing for our app’s frontend:
npm install pusher-js axios react-spinkit
Finally, start the development server by running yarn start
from within the root of the client
directory.
Within the client
directory, locate src/App.css
and change its contents to look like this:
1// src/App.css 2 3 body { 4 font-family: 'Roboto', sans-serif; 5 } 6 7 .App { 8 margin-top: 40px; 9 } 10 11 .App-title { 12 text-align: center; 13 } 14 15 img { 16 max-width: 100%; 17 } 18 19 form { 20 text-align: center; 21 display: flex; 22 flex-direction: column; 23 align-items: center; 24 font-size: 18px; 25 } 26 27 .label { 28 display: block; 29 margin-bottom: 20px; 30 font-size: 20px; 31 } 32 33 input[type="file"] { 34 margin-bottom: 20px; 35 } 36 37 button { 38 border: 1px solid #353b6e; 39 border-radius: 4px; 40 color: #f7f7f7; 41 cursor: pointer; 42 font-size: 18px; 43 padding: 10px 20px; 44 background-color: rebeccapurple; 45 } 46 47 .loading-indicator { 48 display: flex; 49 justify-content: center; 50 margin-top: 30px; 51 } 52 53 .gallery { 54 display: grid; 55 grid-template-columns: repeat(3, 330px); 56 grid-template-rows: 320px 320px 320px; 57 grid-gap: 20px; 58 width: 100%; 59 max-width: 1000px; 60 margin: 0 auto; 61 padding-top: 40px; 62 } 63 64 .photo { 65 width: 100%; 66 height: 100%; 67 object-fit: cover; 68 background-color: #d5d5d5; 69 box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12); 70 }
Open up src/App.js
and change its contents to look like this:
1// src/App.js 2 3 import React, { Component } from 'react'; 4 import axios from 'axios'; 5 import Pusher from 'pusher-js'; 6 import Spinner from 'react-spinkit'; 7 import './App.css'; 8 9 class App extends Component { 10 constructor() { 11 super(); 12 this.state = { 13 images: [], 14 selectedFile: null, 15 loading: false, 16 }; 17 } 18 19 componentDidMount() { 20 this.setState({ 21 loading: true, 22 }); 23 24 axios.get('http://localhost:5000').then(({ data }) => { 25 this.setState({ 26 images: [...data, ...this.state.images], 27 loading: false, 28 }); 29 }); 30 31 const pusher = new Pusher('<your app key>', { 32 cluster: '<your app cluster>', 33 encrypted: true, 34 }); 35 36 const channel = pusher.subscribe('gallery'); 37 channel.bind('upload', data => { 38 this.setState({ 39 images: [data.image, ...this.state.images], 40 }); 41 }); 42 } 43 44 fileChangedHandler = event => { 45 const file = event.target.files[0]; 46 this.setState({ selectedFile: file }); 47 }; 48 49 uploadImage = event => { 50 event.preventDefault(); 51 52 if (!this.state.selectedFile) return; 53 54 this.setState({ 55 loading: true, 56 }); 57 58 const formData = new FormData(); 59 formData.append( 60 'image', 61 this.state.selectedFile, 62 this.state.selectedFile.name 63 ); 64 65 axios.post('http://localhost:5000/upload', formData).then(({ data }) => { 66 this.setState({ 67 loading: false, 68 }); 69 }); 70 }; 71 72 render() { 73 const image = (url, index) => ( 74 <img alt="" className="photo" key={`image-${index} }`} src={url} /> 75 ); 76 77 const images = this.state.images.map((e, i) => image(e.secure_url, i)); 78 79 return ( 80 <div className="App"> 81 <h1 className="App-title">Live Photo Feed</h1> 82 83 <form method="post" onSubmit={this.uploadImage}> 84 <label className="label" htmlFor="gallery-image"> 85 Choose an image to upload 86 </label> 87 <input 88 type="file" 89 onChange={this.fileChangedHandler} 90 id="gallery-image" 91 accept=".jpg, .jpeg, .png" 92 /> 93 <button type="submit">Upload!</button> 94 </form> 95 96 <div className="loading-indicator"> 97 {this.state.loading ? <Spinner name="spinner" /> : ''} 98 </div> 99 100 <div className="gallery">{images}</div> 101 </div> 102 ); 103 } 104 } 105 106 export default App;
I know that’s a lot of code to process in one go, so let me break it down a bit.
The state
of our application is initialized with three values: images
is an array that will contain all images in our photo feed, while selectedFile
represents the currently selected file in the file input. loading
is a Boolean property that acts as a flag to indicate whether the loading component, Spinner
, should be rendered on the page or not.
When the user selects a new image, the fileChangedHandler()
function is invoked, which causes selectedFile
to point to the selected image. The Upload button triggers a form submission, causing uploadImage()
to run. This function basically sends the image to the server and through an axios
post request.
In the componetDidMount()
lifecycle method, we try to fetch all the images that exist in the database (if any) so that on page refresh, the feed is populated with existing images.
The Pusher Channels client library provides a handy bind
function that allows us to latch on to events emitted by the server so that we can update the application state. You need to update the pusher
variable with your app key and cluster before running the code. Here, we’re listening for the upload
event on the gallery
channel. Once the upload
event is triggered, our application is updated with the new image as shown below:
You have now learned how easy it is to create a live feed and update several clients with incoming updates in realtime with Pusher Channels.
Thanks for reading! Remember that you can find the source code of this app in this GitHub repository.