React is now one of the most battle-tested and mature front-end frameworks in the world, and Express.js is its counterpart among back-end/server frameworks. If you’re building an app today, you can’t pick a better duo than this. In this post, I will walk you through building a complete app using these two frameworks, plus Chakra UI for component styling.
Before we dive in, for the impatients like me, here’s the entire codebase on GitHub. Feel free to clone it and take it for a spin.
As a self-taught full-stack dev, I always find myself scouring the web for blog posts/tutorials that build out entire applications from scratch and demonstrate one or several features commonly seen in real-life apps. This kind of post helps devs across a broad spectrum of skill sets and experience.
Beginners learn how to glue together new concepts they have learned recently and turn them into a complete and usable app. Devs with an intermediate level of knowledge can learn how to organize, structure, and apply best practices when building full-stack apps.
So, if you’re just getting into the JS ecosystem — or if you have already built one or two apps but sometimes get confused about whether you’re doing it right — this post is for you.
Having said that, to read and complete this tutorial in one sitting, you will need to have:
If you find yourself missing any of the above items, worry not! The web has plenty of content that will help you get started and prepared for this post.
Please note that my primary OS is Ubuntu, so all the commands in this post assume you have a *nix system.
Before starting any new project, it is easy to get impatient and start writing code right away. However, it is always a good idea to plan out your features and workflow first — at least that’s what I always do. So let’s make a plan for how our app will work.
Our app will have two main parts. One is the client-side React app that lets me upload photos through my browser. The uploaded photos are then shown in a gallery view.
The other part is a server-side API that receives a photo upload, stores it somewhere, and lets us query and display all the uploaded photos.
Before all that programming mumbo-jumbo, however, let’s give our app a catchy name. I’m calling it photato, but feel free to give it a better name yourself, and let me know what you come up with. 🙂
OK, time to code. Let’s make container folders for our app first:
mkdir photato && cd $_ mkdir web mkdir api
We will start by creating our front-end React app. React comes with a handy tool that lets you bootstrap a React app real fast:
cd web npx create-react-app web
Now you should have a bunch of files and folders in the web/
folder, and the output will tell you that by going into the directory and running yarn start
, you can make your app available at http://localhost:3000
.
If you have built websites/web apps before, you might be familiar with the struggle of building UIs with raw HTML and CSS. UI libraries like Bootstrap, Semantic UI, Material Kit, and countless others have long been the saviors of full-stack devs who can’t produce “dribbble famous”-quality design.
In this post, we will look away from the more common, traditional UI libraries mentioned above and use Chakra UI, built with accessibility in mind on the utility-first CSS framework Tailwind CSS.
Following the Chakra UI get-started guide, run the following commands in your React app’s root directory:
yarn add @chakra-ui/core @emotion/core @emotion/styled emotion-theming
Chakra UI allows you to customize its look and feel through theming very easily, but for this post, we will stick to its default styling.
The last thing we need before we can start coding is one more library to get a pretty-looking gallery:
yarn add react-photo-gallery
Our app’s code will be encapsulated within the src/
directory, so let’s take a look at it. create-react-app gave us a bunch of files, and with the help of Chakra UI, we can basically get rid of all the CSS stuff. Remove the App.css
, index.css
, and logo.svg
files:
cd src rm -r App.css index.css logo.svg
This gives us a clean base on which to start building. Now let’s look at our setup for the server API app. Navigate back to the api/
folder and create a new file by running the following commands:
cd ../../api touch package.json
Now copy and paste the following code into the package.json
file:
{ "name": "api", "version": "1.0.0", "description": "Server api for photato", "main": "dist", "author": "Foysal Ahamed", "license": "ISC", "entry": "src/index.js", "scripts": { "dev": "NODE_ENV=development nodemon src/index.js --exec babel-node", "start": "node dist", "build": "./node_modules/.bin/babel src --out-dir dist --copy-files", "prestart": "npm run -s build" }, "eslintConfig": { "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 7, "sourceType": "module" }, "env": { "node": true }, "rules": { "no-console": 0, "no-unused-vars": 1 } }, "dependencies": { "cors": "^2.8.4", "express": "^4.13.3", "mysql2": "^1.6.1", "sequelize": "^5.18.4" }, "devDependencies": { "@babel/cli": "^7.1.2", "@babel/core": "^7.1.2", "@babel/node": "^7.0.0", "@babel/plugin-proposal-class-properties": "^7.1.0", "@babel/preset-env": "^7.1.0", "eslint": "^3.1.1", "eslint-config-airbnb": "^17.1.0", "eslint-plugin-jsx-a11y": "^6.2.1", "nodemon": "^1.9.2" } }
Notice that we have quite a few dev dependencies, and they are necessary to enable writing our app using the latest ES6 syntax transpiled through Babel.
Babel is a magnificent tool and full of wonderful features, but for our purposes, you need to know almost nothing about it. In our case, we just need to create a .babelrc
file alongside the package.json
file and put the following config in it:
{ "presets": [[ "@babel/preset-env", { "targets": { "node": "current" } } ]], "plugins": [ "@babel/plugin-proposal-class-properties" ] }
There’s also a few other dependencies, like Express and Sequelize, and we will see their usage later. That’s all the setup we need for our server app, but before we move on, let’s install all the packages by running the npm install
command in the root of the api/
folder. This command will generate a node_modules/
folder and a package.lock.json
file.
We will start with the App.js
file. Let’s clean up the generated code and fill it with the following code:
import React from 'react'; import { ThemeProvider } from '@chakra-ui/core'; import AppContainer from './app.container'; function App() { return ( <div> <ThemeProvider> <AppContainer /> </ThemeProvider> </div> ); } export default App;
This simplifies our entry component and delegates the actual logic to another container named AppContainer
, which is wrapped within ThemeProvider
from Chakra UI. The ThemeProvider
component ensures all of its children can be styled with the Chakra UI theme or any custom theme you may want to pass to it.
With that out of the way, we will never have to touch App.js
again. Let’s create the new file touch src/app.container.js
and fill it with the following code:
import React from 'react'; import PhotoGallery from 'react-photo-gallery'; import Header from './header.component'; function AppContainer() { const photos = [{ src: 'http://placekitten.com/200/300', width: 3, height: 4, }, { src: 'http://placekitten.com/200/200', width: 1, height: 1, }, { src: 'http://placekitten.com/300/400', width: 3, height: 4, }, ]; return ( <> <Header/> <PhotoGallery photos={photos} /> </> ); } export default App;
This component renders two other components, Header
and PhotoGallery
, where PhotoGallery
is provided by the npm photo gallery lib. Note that we are passing a photos array containing placeholder images to the PhotoGallery
component. We will get back to this later in the post and replace the heartwarming kitten photos with our own uploaded photos.
The other component, Header
, is being imported from a file that doesn’t exist yet, so let’s create it: touch src/header.component.js
. Put the following code in the file:
import React from 'react'; import { Flex, Button, Text } from '@chakra-ui/core'; function Header ({ isUploading = false, onPhotoSelect, }) { return ( <Flex px="4" py="4" justify="space-between" > <Text as="div" fontSize="xl" fontWeight="bold" > <span role="img" aria-labelledby="potato" > 🥔 </span> <span role="img" aria-labelledby="potato" > 🍠 </span> Photato </Text> <Flex align="end"> <Button size="sm" variant="outline" variantColor="blue" isLoading={isUploading} loadingText="Uploading..." > Upload Photo </Button> </Flex> </Flex> ); }; export default Header;
If you followed all the above steps, the app in your browser should render something like this:
Let’s break down what we’ve done so far.
The Header
component wraps all its children in a Chakra UI Flex
component that renders an HTML div
element with CSS style display: flex
. Being a utility-based CSS framework, Chakra UI allows you to pass various props to its components to style them to your liking, and you will see this used throughout our app.
In our wrapper Flex
component, px
and py
props give it a nice horizontal and vertical padding (respectively), and the justify="space-between"
prop ensures that the elements inside it are rendered with equal spacing between them. If you’re not very familiar with CSS flexbox, I highly encourage you to learn more about this amazing layout tool.
Inside the Flex
container, we have a Text
on the left of the screen and a Button
for uploading new photos on the right of the screen. Let’s take a closer look at the Button
here.
We use size="sm"
to give it a smaller size, but you can play around with lg
, xs
, etc. values to change the size. The variant="outline"
prop makes it a bordered button instead of filling it with color — and speaking of color, variantColor="blue"
makes the border and the text blue. There are several other colors available out of the box from Chakra UI, and I would highly recommend reading up on it.
So far, we have been focused on the looks. Let’s talk about the functionality. This component is a great example of one of the core principles of writing clean and easily maintainable front-end code. It’s a dumb component that only renders the markup, and there is no logic being handled. To make it functional, we pass props to it from the parent. It expects two props:
isUploading
, which is a boolean and defaults to false
. This prop determines the state of the Upload Photo button. When it is true, the button will go into a loading
state to give the user a feedback that uploading is happening in the background.onPhotoSelect
, which is a function that will be triggered when the user selects a new photo to upload. We will circle back to this later.This way of writing components really helps you plan out the functionality and architecture one small chunk at a time. Without implementing the actual logic, we have already planned out how the button will work based on the requirements of our app.
We have a solid and functional base for our front-end app now, so let’s pause here for a moment and start setting up our back end.
The entry point for our server api will be the src/index.js
file, so let’s create that:
mkdir src touch index.js
Then put the following code in that file:
import http from 'http'; import cors from 'cors'; import express from 'express'; import { Sequelize } from 'sequelize'; const config = { port: 3001, database: { username: "root", password: "admin", host: "localhost", port: "3306", dialect: "mysql", database: "photato", } }; let app = express(); app.server = http.createServer(app); // 3rd party middlewares app.use(cors({})); // connect to db const database = new Sequelize(config.database); database.sync().then(() => { app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.server.listen(config.port, () => { console.log(`Started on port ${app.server.address().port}`); }); }); export default app;
This is a bare-bones setup; let’s break it down block by block.
import http from 'http'; import cors from 'cors'; import express from 'express'; import { Sequelize } from 'sequelize';
Imports the necessary modules from Node’s built-in HTTP package and other third-party packages installed through npm.
const config = { port: 3001, database: { username: "root", password: "admin", host: "localhost", port: "3306", dialect: "mysql", database: "photato", } };
This defines configurations for the database and server port where the app will be available. You will need to change the database password and username based on your MySQL database setup. Also, make sure you create a new database schema named photato
in your db.
Please note that in production-ready applications, you would pass the configs from env var instead of hardcoding them.
let app = express(); app.server = http.createServer(app); // 3rd party middlewares app.use(cors({}));
This initializes the Express app and creates a server instance using Node’s http.createServer
method. Express allows plugging in various functionalities through middlewares. One such middleware we are going to use enables CORS requests for our API.
Right now, we are allowing CORS requests from any origin, but you can add more fine-grained config to allow requests only from your front-end app’s domain name for security purposes.
// connect to db const database = new Sequelize(config.database); database.sync().then(() => { app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.server.listen(config.port, () => { console.log(`Started on port ${app.server.address().port}`); }); });
This initializes a Sequelize instance that connects to our MySQL database based on our config. Once the connection is established, it adds a handler for the /
endpoint of our API that returns a JSON-formatted response. Then the app is opened up through the server port specified in the config.
We can now boot up our app and see what we have achieved so far. Run npm run dev
in the api/
folder and then go to http://localhost:3001
. You should see something like this:
Handling file uploads has a lot of edge cases and security concerns, so it’s not a very good idea to build it from scratch. We will use an npm package called Multer that makes it super easy. Install the package by running npm i --save multer
, and then make the following changes in the src/index.js
file:
import http from 'http'; import cors from 'cors'; import multer from 'multer'; import { resolve } from 'path'; //previously written code here const config = { port: 3001, uploadDir: `${resolve(__dirname, '..')}/uploads/`, database: { username: "root", password: "admin", host: "localhost", port: "3306", dialect: "mysql", database: "photato", } }; //previously written code here // connect to db const database = new Sequelize(config.database); // setup multer const uploadMiddleware = multer({ dest: config.uploadDir, fileFilter: function (req, file, cb) { if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { return cb(new Error('Only image files are allowed!')); } cb(null, true); }, }).single('photo'); //previously written code here app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.post('/photo', uploadMiddleware, async (req, res) => { try { const photo = await Photo.create(req.file); res.json({success: true, photo}); } catch (err) { res.status(422).json({success: false, message: err.message}); } }); //previously written code here
Overview of the additions:
api/upload/
, which doesn’t exist yet. So let’s create the folder as well: mkdir upload
photo
and saves the file in the specified folderThis line const photo = await Photo.create(req.file);
, however, needs a bit more explanation. ModelName.create(modelData)
is how you create a new row in a database table through Sequelize, and in the above code, we expect a Sequelize model named Photo
to exist, which we haven’t created yet. Let’s fix that by running touch src/photo.model.js
and putting the following code in that file:
import { Model, DataTypes } from 'sequelize'; const PhotoSchema = { originalname: { type: DataTypes.STRING, allowNull: false, }, mimetype: { type: DataTypes.STRING, allowNull: false, }, size: { type: DataTypes.INTEGER, allowNull: false, }, filename: { type: DataTypes.STRING, allowNull: false, }, path: { type: DataTypes.STRING, allowNull: false, }, }; class PhotoModel extends Model { static init (sequelize) { return super.init(PhotoSchema, { sequelize }); } }; export default PhotoModel;
That’s a lot of code, but the gist of it is that we are creating a Sequelize model class with a schema definition where the fields (table columns) are all strings (translates to VARCHAR in MySQL) except for the size field, which is an integer. The schema looks like this because after handling uploaded files, Multer provides exactly that data and attaches it to req.file
.
Going back to how this model can be used in our route handler, we need to connect the model with MySQL through Sequelize. In our src/index.js
file, add the following lines:
// previously written code import { Sequelize } from 'sequelize'; import PhotoModel from './photo.model'; // previously written code // connect to db const database = new Sequelize(config.database); // initialize models const Photo = PhotoModel.init(database); // previously written code
So now that we have pieced together the missing case of the Photo
, let’s add one more endpoint to our API and see one more use of the model:
// previously written code app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.get('/photo', async (req, res) => { const photos = await Photo.findAndCountAll(); res.json({success: true, photos}); }); // previously written code
This adds a GET request handler at the /photo
path and returns a JSON response containing all the previously uploaded photos. Notice that Photo.findAndCountAll()
returns an object that looks like this:
{ count: <number of entries in the model/table>, rows: [ {<object containing column data from the table>}, {<object containing column data from the table>}, .... ] }
With all the above changes, your src/index.js
file should look like this:
import http from 'http'; import cors from 'cors'; import multer from 'multer'; import express from 'express'; import { resolve } from 'path'; import { Sequelize } from 'sequelize'; import PhotoModel from './photo.model'; const config = { port: 3001, uploadDir: `${resolve(__dirname, '..')}/uploads/`, database: { username: "root", password: "admin", host: "localhost", port: "3306", dialect: "mysql", database: "photato", } }; let app = express(); app.server = http.createServer(app); // 3rd party middlewares app.use(cors({})); // connect to db const database = new Sequelize(config.database); // initialize models const Photo = PhotoModel.init(database); // setup multer const uploadMiddleware = multer({ dest: config.uploadDir, fileFilter: function (req, file, cb) { if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) { return cb(new Error('Only image files are allowed!')); } cb(null, true); }, }).single('photo'); database.sync().then(() => { app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.get('/photo', async (req, res) => { const photos = await Photo.findAndCountAll(); res.json({success: true, photos}); }); app.post('/photo', uploadMiddleware, async (req, res) => { try { const photo = await Photo.create(req.file); res.json({success: true, photo}); } catch (err) { res.status(400).json({success: false, message: err.message}); } }); app.server.listen(process.env.PORT || config.port, () => { console.log(`Started on port ${app.server.address().port}`); }); }); export default app;
You’ve come this far, congrats! Go grab a coffee or something refreshing and get ready to cross the finish line in style.
At this point, we have two apps: one is a browser-based React app that runs on http://localhost:3000
, and the other is a server-side Node.js app running on http://localhost:3001
.
So far, however, they have been strangers to each other, living their own lives. So, naturally, the next step is to marry the two and hope that they live happily ever after!
We are going to use the browser’s Fetch API to talk to our server app from the React app. To keep our server communication encapsulated, we will create a new file:
cd ../web/ touch src/api.js
Then let’s add the following functions in that file:
const API_URL = 'http://localhost:3001'; export async function getPhotos () { const response = await fetch(`${API_URL}/photo`); return response.json(); }; export async function uploadPhoto (file) { if (!file) return null; const photoFormData = new FormData(); photoFormData.append("photo", file); const response = await fetch(`${API_URL}/photo`, { method: 'POST', body: photoFormData, }); return response.json(); };
Let’s break it down:
API_URL
that points to the URL where our server app is availablegetPhotos
makes a GET request to the /photo
endpoint of our server and parses the response as JSON before returning ituploadPhoto
receives a file
parameter and builds a FormData
object that can be used to POST the file to the /photo
endpoint of our server. After sending the request, it parses the response as JSON and returns itLet’s use these nifty little functions, shall we? Open up the src/app.container.js
file and add the following new lines in it:
import React, { useState } from 'react'; // previously written code... import { uploadPhoto } from './api'; function AppContainer() { const [isUploading, setIsUploading] = useState(false); async function handlePhotoSelect (file) { setIsUploading(true); await uploadPhoto(file); setIsUploading(false); }; return ( // previously written code... <Header isUploading={isUploading} onPhotoSelect={handlePhotoSelect} /> // previously written code... ); }
With the above changes, we have added state Hooks in our App
component. If you’re not familiar with Hooks and states, I encourage you to read up on it, but in short, state lets you re-render your UI whenever your state value changes.
Whenever our function handlePhotoSelect
is executed with a file argument, it will first change isUploading
‘s value to true
. Then it will pass the file data to our uploadPhoto
function, and when that finishes, it will switch isUploading
‘s value to false
:
<Header isUploading={isUploading} onPhotoSelect={handlePhotoSelect} />
Then, we pass our isUploading
state as a prop to our header component — and, if you recall, when isUploading
changes to true
, our Upload Photo button will transition into a loading state.
The second prop onPhotoSelect
gets the function handlePhotoSelect
. Remember when we wrote our Header
component we defined the onPhotoSelect
prop but never used it? Well, let’s settle that by making the following changes in the src/header.component.js
file:
// previously written code... function Header ({ isUploading = false, onPhotoSelect, }) { let hiddenInput = null; // previously written code... return ( // previously written code... <Button // previously written code... onClick={() => hiddenInput.click()} > Upload Photo </Button> <input hidden type='file' ref={el => hiddenInput = el} onChange={(e) => onPhotoSelect(e.target.files[0])} /> // previously written code... ); };
The above changes add a hidden file input element and store its reference in the hiddenInput
variable. Whenever the Button
is clicked, we trigger a click on the file input element using the reference variable.
From there on, the browser’s built-in behavior kicks in and asks the user to select a file. After the user makes a selection, the onChange
event is fired, and when that happens, we call the onPhotoSelect
prop function and pass the selected file as its argument.
This completes one communication channel between our front-end and back-end apps. Now, you should be able to follow the below steps and get a similar result along the way:
http://localhost:3000
http://localhost:3001/photos
and a JSON response coming back.Here’s how mine looks:
To verify that the upload worked, go into the api/uploads
directory, and you should see a file there. Try uploading more photos and see if they keep showing up in that folder. This is great, right? We are actually uploading our photos through our React app and saving it with our Node.js server app.
Sadly, the last step to tie it all together is to replace those kitty cats with our uploaded photos. To do that, we need to be able to request the server for an uploaded photo and get the photo file back. Let’s do that by adding one more endpoint in the api/src/index.js
file:
// previously written code... app.get('/', (req, res) => { res.json({app: 'photato'}); }); app.get("/photo/:filename", (req, res) => { res.sendFile(join(config.uploadDir, `/${req.params.filename}`)); }); // previously written code...
The new endpoint allows us to pass any string in place of :filename
through the URL, and the server looks for a file with that name in our uploadDir
and sends the file in the response. So, if we have a file named image1
, we can access that file by going to http://localhost:3001/photo/image1
, and going to http://localhost:3001/photo/image2
will give us the file named image2
.
That was easy, right? Now back to the front end. Remember how our initial boilerplate photos
variable looked like? The data that we get from the server is nothing like that, right? We will fix that first. Go back to the web/src/api.js
file and make the following changes:
export async function getPhotos () { const response = await fetch(`${API_URL}/photo`); const photoData = await response.json(); if (!photoData.success || photoData.photos.count < 1) return []; return photoData.photos.rows.map(photo => ({ src: `${API_URL}/photo/${photo.filename}`, width: 1, height: 1, })); };
The extra lines are just transforming our server-sent data into a format that can be passed to our PhotoGallery
component. It builds the src
URL from the API_URL
and the filename property of each photo.
Back in the app.container.js
file, we add the following changes:
import React, { useState, useEffect } from 'react'; // previously written code... import { uploadPhoto, getPhotos } from './api'; function AppContainer() { const [isUploading, setIsUploading] = useState(false); const [photos, setPhotos] = useState([]); useEffect(() => { if (!isUploading) getPhotos().then(setPhotos); }, [isUploading]); // previously written code... }
That’s it! That’s all you need to show the uploaded photos in the image gallery. We replaced our static photos
variable with a state variable and initially set it to an empty array.
The most notable thing in the above change is the useEffect
function. Every time isUploading
state is changed, as a side effect, React will run the first argument function in the useEffect
call.
Within that function, we check if isUploading
is false
, meaning that a new upload is either complete or the component is loaded for the first time. For only those cases, we execute getPhotos
, and the results of that function are stored in the photos
state variable.
This ensures that, besides loading all the previous photos on first load, the gallery is also refreshed with the newly uploaded photo as soon as the upload is complete without the need to refresh the window.
This is fun, so I uploaded four consecutive photos, and this is how my photato looks now:
While we do have a functioning app that meets all the requirements we set out to build, it could use some UX improvements. For instance, upload success/error does not trigger any feedback for the user. We will implement that by using a nifty little toast
component from Chakra UI.
Let’s go back to the web/src/app.container.js
:
// previously written code... import PhotoGallery from 'react-photo-gallery'; import { useToast } from '@chakra-ui/core'; // previously written code... const [photos, setPhotos] = useState([]); const toast = useToast(); async function handlePhotoSelect (file) { setIsUploading(true); try { const result = await uploadPhoto(file); if (!result.success) throw new Error("Error Uploading photo"); toast({ duration: 5000, status: "success", isClosable: true, title: "Upload Complete.", description: "Saved your photo on Photato!", }); } catch (err) { toast({ duration: 9000, status: "error", isClosable: true, title: "Upload Error.", description: "Something went wrong when uploading your photo!", }); } setIsUploading(false); }; // previously written code...
With the above changes, you should get a little green toast notification at the bottom of your screen every time you upload a new photo. Also notice that in case of error, we are calling the toast with status:"error"
, which will show a red toast instead of green.
This is how my success toast looks:
The gallery is made up of thumbnails. Shouldn’t we be able to see the full image as well? That would improve the UX a lot, right? So let’s build a full-screen version of the gallery with the react-images package.
Start by running yarn add react-images
within the web/
directory. Then, pop open the src/app.container.js
file and add the following bits:
import React, { useState, useEffect, useCallback } from 'react'; import Carousel, { Modal, ModalGateway } from "react-images"; // previously written code... function AppContainer() { const [currentImage, setCurrentImage] = useState(0); const [viewerIsOpen, setViewerIsOpen] = useState(false); const openLightbox = useCallback((event, { photo, index }) => { setCurrentImage(index); setViewerIsOpen(true); }, []); const closeLightbox = () => { setCurrentImage(0); setViewerIsOpen(false); }; // previously written code... return ( // previously written code... <PhotoGallery photos={photos} onClick={openLightbox} /> <ModalGateway> {viewerIsOpen && ( <Modal onClose={closeLightbox}> <Carousel currentIndex={currentImage} views={photos.map(x => ({ ...x, srcset: x.srcSet, caption: x.title }))} /> </Modal> )} </ModalGateway> // previously written code... ); }
Here’s what the changes are doing:
react-images
to show a full-screen gallerycurrentImage
and viewerIsOpen
. We will see how they are used soonopenLightbox
, that gets triggered when the user clicks on any of the photos from the photo gallery. When executed, the function sets viewerIsOpen
to true and sets the index number of the photo that was clickedcloseLightbox
, is created that essentially closes the full-screen galleryviewerIsOpen
is true
, we render the modal lightbox containing the Carousel
component from the react-images libModal
component receives the prop onClose={closeLightbox}
so that the user can close the full-screen gallerycurrentImage
index number to it so it knows which photo will be shown first. In addition, we transform all the photos from the gallery and pass them to the carousel so that the user can swipe through all the photos in full-screen modeThe end result:
What we have built throughout this journey is a complete and functional app, but there’s a lot of room for improvement. Architecture, file-folder structure, testability — all of these things should be considered for refactoring both our client- and server-side apps. I would like you to take this as homework and add unit and/or integration testing to the codebase.
Chakra UI is a promising new tool and has numerous components that are hard to cover in one post, so I highly encourage you to go through its docs to learn more.
These days, saving uploaded content on the same disk where your app is running is somewhat frowned upon. Luckily, Multer has a lot of handy third-party plugins that would allow you to upload files directly to external storage such as S3. If you ever deploy your server app on hosting services like ZEIT Now or Netlify, they will come in handy.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowExplore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.
Build a real-time image background remover in Vue using Transformers.js and WebGPU for client-side processing with privacy and efficiency.
Optimize search parameter handling in React and Next.js with nuqs for SEO-friendly, shareable URLs and a better user experience.
One Reply to "Building a photo gallery app from scratch with Chakra UI"
in the app.container.js at
the bottom of
the code it should be
export default AppContainer;