Categories
React

How to Upload Files with React and Node.js

Spread the love

File upload is a common operation for web applications. In Node.js, with the Express web framework and the Multer library, adding file upload feature to your app is very easy.

To add file upload feature to your app, first you need an input field with type file. By default this type of input renders itself as a button which is difficult to style. The user clicks the dialog and if you attach an onChange handler to it, then you can access the file object by using JavaScript. The onChange handler takes a parameter which is the event object. The object has the file under the target.files property. Once you have that, you can add it to the FormData object with the append function to attach it to the form submission.

In this article, we will make a photo manager application that lets users enter a name, description, and upload a photo with the text.

We will use React for the frontend and Express with Multer for backend.

Backend: Node.js for File Upload

We start by building the backend. We create the project folder and a backend folder inside the project folder. Then go into the backend folder and run npx express-generator to generate the code files for our back end app.

Next, we run npm i to install the packages for our backend provided by the generator. We also need Babel to be able to use the import syntax, the CORS package for cross-domain communication, Multer for file upload with Express, Sequelize as our ORM, and SQLite3 as the database. We will use SQLite for simplicity.

To install all the packages, run npm i @babel/cli @babel/core @babel/node @babel/preset-env cors multer sequelize sqlite3 Then create a file called .babelrc in the root of the backend folder and add:

{
    "presets": [
        "@babel/preset-env]"
    ]
}

Then in the scripts section of package.json, we add:

"start": "nodemon --exec npm run babel-node --  ./bin/www",
"babel-node": "babel-node"

This lets us run our app with Babel instead of regular Node runtime. We should also install nodemon to watch for file changes and restart the app. Install it globally by running npm i -g nodemon.

Next we run npx sequelize-cli init in the backend folder to create the Sequelize ORM code to let us make and run migrations.

After that finishes, we should have a config.js created. In there, replace the existing code with:

{
  "development": {
    "dialect": "sqlite",
    "storage": "development.db"
  },
  "test": {
    "dialect": "sqlite",
    "storage": "test.db"
  },
  "production": {
    "dialect": "sqlite",
    "storage": "production.db"
  }
}

This specifies SQLite as our database.

Next we create a migration with the model:

npx sequelize-cli model:create --name Photo --attributes name:string,description:string,photoPath:string

Notice that we no spaces between commas after the attributes flag.

Run npx sequelize-cli db:migrate to create the database.

After that, we create the routes for manipulating photos. In the routes folder, create photos.js and add:

var express = require("express");
var multer = require("multer");
var router = express.Router();
const models = require("../models");
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./uploads");
  },
  filename: (req, file, cb) => {
    cb(null, `${file.fieldname}_${+new Date()}.jpg`);
  }
});

const upload = multer({
  storage
});

/* GET users listing. */
router.get("/", async (req, res, next) => {
  const photos = await models.Photo.findAll();
  res.json(photos);
});

router.post("/add", upload.single("photo"), async (req, res, next) => {
  try {
    const path = req.file.path;
    const { name, description } = req.body;
    const entry = await models.Photo.create({
      name,
      description,
      photoPath: path
    });
    res.json(entry);
  } catch (ex) {
    res.status(400).send({ error: ex });
  }
});

router.put("/edit", upload.single("photo"), async (req, res, next) => {
  try {
    const path = req.file && req.file.path;
    const { id, name, description } = req.body;
    let params = {};
    if (path) {
      params = {
        name,
        description,
        photoPath: path
      };
    } else {
      params = {
        name,
        description
      };
    }
    const photo = await models.Photo.update(params, {
      where: {
        id
      }
    });
    res.json(photo);
  } catch (ex) {
    res.status(400).send({ error: ex });
  }
});

router.delete("/delete/:id", async (req, res, next) => {
  const { id } = req.params;
  await models.Photo.destroy({
    where: {
      id
    }
  });
  res.json({ deleted: id });
});

module.exports = router;

These are all the routes for manipulating photos. The models file is created by Sequelize CLI, which contains the model objects which we manipulate to save our data to the Photos table. We have a GET route to get the photos with findAll, a POST route for saving a Photo with create, a PUT route that updates the model with update, and a DELETE route that deletes a Photo with destroy.

To add file upload, we use the multer package which we installed earlier. It is very simple to add. We just specify the file name and folder to upload the file to, like in this block of code:

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./uploads");
  },
  filename: (req, file, cb) => {
    cb(null, `${file.fieldname}_${+new Date()}.jpg`);
  }
});

const upload = multer({
  storage
});

We include the multer middleware with the routes we want to access files with like so:

upload.single("photo")

This specifies that we let the frontend upload a file in the FormData with the photo field.

Then we can access the file path of the saved file in the routes by using req.file.path to save the path to our database.

We need to create an uploads folder in the backend folder to save the files.

Next in app.js, we replace the existing code with:

var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");

var indexRouter = require("./routes/index");
var photosRouter = require("./routes/photos");

var app = express();

// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(cors());

app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "uploads")));

app.use("/", indexRouter);
app.use("/photos", photosRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

// render the error page
  res.status(err.status || 500);
  res.render("error");
});

module.exports = app;

In this file, we added app.use(cors()); to enable frontend cross-domain requests to backend. We expose the photos routes to the frontend with the following code:

`var photosRouter = require(“./routes/photos”);
`app.use("/photos", photosRouter);

And we add a static path route to access the files:

app.use(express.static(path.join(__dirname, "uploads")));

Now that back end is done, we can move on to frontend.

Frontend: File Upload with React

We will use React to build the frontend with MobX for simple state management. To create the skeleton code, we run npx create-react-app frontend.

Next we have to install some packages. We will install MobX, Bootstrap for styling, React Router for routing, Formik and Yup for form value handling and form validation respectively, and Axios for making HTTP requests.

To do this run npm i axios bootstrap formik mobx mobx-react react-bootstrap react-router-dom yup to install all the packages.

With all the packages installed, we can start writing code. To start, we replace the existing code in App.js with:

import React from "react";
import { Router, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
import TopBar from "./TopBar";
import { createBrowserHistory as createHistory } from "history";
import { photosStore } from "./store";
const history = createHistory();

function App() {
  return (
    <div className="App">
      <Router history={history}>
        <TopBar />
        <Route
          path="/"
          exact
          component={props => (
            <HomePage {...props} photosStore={photosStore} />
          )}
        />
      </Router>
    </div>
  );
}

export default App;

This adds a top bar which we will create next, and we define the routes so that we see the home page and address generator page when we go to the defined URLs.

Next create HomePage.js in the src for our home page. In it, we will display the table of entries, which has the name, description, the photo, and buttons to open the add / edit photo forms, and delete the entries.

In the file, we add:

import React, { useState, useEffect } from "react";
import * as yup from "yup";
import "./HomePage.css";
import Modal from "react-bootstrap/Modal";
import PhotoForm from "./PhotoForm";
import Button from "react-bootstrap/Button";
import Table from "react-bootstrap/Table";
import { observer } from "mobx-react";
import { getPhotos, deletePhoto, APIURL } from "./requests";

function HomePage({ photosStore }) {
  const [show, setShow] = useState(false);
  const [showEdit, setShowEdit] = useState(false);
  const [initialized, setInitialized] = useState(false);
  const [selectedPhoto, setSelectedPhoto] = useState({});
  const handleClose = () => setShow(false);
  const handleShow = () => setShow(true);

  const handleEditClose = () => setShowEdit(false);
  const handleEditShow = photo => {
    setSelectedPhoto(photo);
    setShowEdit(true);
  };

  const getAllPhotos = async () => {
    const response = await getPhotos();
    photosStore.setPhotos(response.data);
  };

  const deletePhotoById = async id => {
    await deletePhoto(id);
    await getAllPhotos();
  };

  const onSave = () => {
    setShow(false);
    setShowEdit(false);
  };

  useEffect(() => {
    if (!initialized) {
      getAllPhotos();
      setInitialized(true);
    }
  });

  return (
    <div className="home-page">
      <h1>Photos</h1>
      <Button variant="primary" onClick={handleShow}>
        Add Photo
      </Button>

      <Table striped bordered hover style={{ marginTop: 10 }}>
        <thead>
          <tr>
            <th>Name</th>
            <th>Description</th>
            <th>Photo</th>
            <th></th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {photosStore.photos.map((p, i) => {
            const splitPath = p.photoPath.split("");
            const path = splitPath[splitPath.length - 1];
            return (
              <tr key={i}>
                <td>{p.name}</td>
                <td>{p.description}</td>
                <td>
                  <img src={`${APIURL}/${path}`} style={{ width: 200 }} />
                </td>
                <td>
                  <Button onClick={handleEditShow.bind(this, p)}>Edit</Button>
                </td>
                <td>
                  <Button onClick={deletePhotoById.bind(this, p.id)}>
                    Delete
                  </Button>
                </td>
              </tr>
            );
          })}
        </tbody>
      </Table>

      <Modal show={show} onHide={handleClose}>
        <Modal.Header closeButton>
          <Modal.Title>Add Photo</Modal.Title>
        </Modal.Header>

        <Modal.Body>
          <PhotoForm
            edit={false}
            photosStore={photosStore}
            onSave={onSave.bind(this)}
          />
        </Modal.Body>
      </Modal>

     <Modal show={showEdit} onHide={handleEditClose}>
        <Modal.Header closeButton>
          <Modal.Title>Edit Photo</Modal.Title>
        </Modal.Header>

        <Modal.Body>
          <PhotoForm
            edit={true}
            photosStore={photosStore}
            selectedPhoto={selectedPhoto}
            onSave={onSave.bind(this)}
          />
        </Modal.Body>
      </Modal>
    </div>
  );
}

export default observer(HomePage);

The Table is provided by React Bootstrap. We just display every entry in its own row. The entries are provided by our MobX store which we will create. The modals contain the PhotoForm which has all the inputs for manipulating photos. We will use it for both add and edit so we need to pass in the edit prop to distinguish between add and edit. We also pass in a onSave function so that we can close the modals. If there is an entry selected for edit, we also pass in selectedPhoto prop, which contains the entry that the user is editing.

Next we build the photo form. Create a file called PhotoForm.js in the src folder and add:

import React from "react";
import { useEffect, useState } from "react";
import { Formik } from "formik";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import * as yup from "yup";
import "./PhotoForm.css";
import { getPhotos, addPhoto, editPhoto } from "./requests";
import { observer } from "mobx-react";

const schema = yup.object({
  name: yup.string().required("Name is required"),
  description: yup.string().required("Description is required")
});

function PhotoForm({ photosStore, edit, selectedPhoto, onSave }) {
  const fileUpload = React.createRef();
  const [photo, setPhoto] = useState(null);
  const [fileName, setFileName] = useState("");
  const getAllPhotos = async () => {
    const response = await getPhotos();
    photosStore.setPhotos(response.data);
  };

  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid) {
      return;
    }
    try {
      let bodyFormData = new FormData();
      if (!edit) {
        bodyFormData.set("name", evt.name);
        bodyFormData.set("description", evt.description);
        bodyFormData.append("photo", photo);
        await addPhoto(bodyFormData);
      } else {
        bodyFormData.set("id", selectedPhoto.id);
        bodyFormData.set("name", evt.name);
        bodyFormData.set("description", evt.description);
        if (photo) {
          bodyFormData.append("photo", photo);
        }
        await editPhoto(bodyFormData);
      }
    } catch (error) {
      alert("Upload must be an image");
    }

  await getAllPhotos();
    onSave();
  };

  const setFile = evt => {
    setPhoto(evt.target.files[0]);
    setFileName(evt.target.files[0].name);
  };

  const openUploadDialog = () => {
    fileUpload.current.click();
  };

  return (
    <div>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={edit ? selectedPhoto : {}}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="name">
                <Form.Label>Name</Form.Label>
                <Form.Control
                  type="text"
                  name="name"
                  placeholder="Name"
                  value={values.name || ""}
                  onChange={handleChange}
                  isInvalid={touched.name && errors.name}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.name}
                </Form.Control.Feedback>
              </Form.Group>
              <Form.Group as={Col} md="12" controlId="description">
                <Form.Label>Description</Form.Label>
                <Form.Control
                  type="text"
                  name="description"
                  placeholder="Description"
                  value={values.description || ""}
                  onChange={handleChange}
                  isInvalid={touched.description && errors.description}
                />

                <Form.Control.Feedback type="invalid">
                  {errors.description}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="photo">
                <input
                  type="file"
                  ref={fileUpload}
                  name="photo"
                  style={{ display: "none" }}
                  onChange={setFile}
                />
                <div className="file-box">
                  <Button type="button" onClick={openUploadDialog}>
                    Upload Photo
                  </Button>
                  <span style={{ paddingLeft: "10px", marginTop: "5px" }}>
                    {fileName}
                  </span>
                </div>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: "10px" }}>
              Save
            </Button>
          </Form>
        )}
      </Formik>
    </div>
  );
}

export default observer(PhotoForm);

In this file, we have the file input for getting the photo file and the form fields for name and description. To get the file, we pass the setFile function into the onChange prop of the file input, where we get the file object by using ev.target.files[0]. The [0] means that we only want the first file.

We wrapped the React Boostrap form inside the Formik component to automatically handle form values, which will be available in the parameter of the handleSubmit function. In that function, we validate the text inputs with the schema.validation function. Then we add the text data and file to the FormData object. After everything is added, we submit the FormData object to our back end via HTTP.

After that, we call getAllPhotos to get the latest data and set the new data in our MobX store. Then we call onSave function, which is passed in from HomePage so that we can close the dialog box after everything is done.

Next create PhotoForm.css and add:

.file-box {
  display: flex;
}

Then create requests.js in the src folder and add:

const axios = require("axios");
export const APIURL = "http://localhost:3000";

export const getPhotos = () => axios.get(`${APIURL}/photos`);

export const addPhoto = data =>
  axios({
    method: "post",
    url: `${APIURL}/photos/add`,
    data,
    config: { headers: { "Content-Type": "multipart/form-data" } }
  });

export const editPhoto = data =>
  axios({
    method: "put",
    url: `${APIURL}/photos/edit`,
    data,
    config: { headers: { "Content-Type": "multipart/form-data" } }
  });

export const deletePhoto = id => axios.delete(`${APIURL}/photos/delete/${id}`);

We need these functions to send the requests to the backend. Note that we have config: { headers: { “Content-Type”: “multipart/form-data” } } in the post so that we send form data instead of the default JSON to the back end. Form data can include files but JSON cannot.

Next we create the MobX Store. Create a file called store.js and add:

import { observable, action, decorate } from "mobx";

class PhotosStore {
  photos = [];

  setPhotos(photos) {
    this.photos = photos;
  }
}

PhotosStore = decorate(PhotosStore, {
  photos: observable,
  setPhotos: action
});

const photosStore = new PhotosStore();

export { photosStore };

We have the function setPhotos to put the photo data in the store, which we used in HomePage and PhotoForm and we instantiated it before exporting so that we only have to do it in one place.

Next we create the top bar by creating a TopBar.js file in the src folder and add:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";

function TopBar({ location }) {
  const { pathname } = location;

  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Photo App</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/" active={pathname == "/"}>
            Home
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}

export default withRouter(TopBar);

This contains the React Bootstrap Navbar to show a top bar with a link to the home page and the name of the app.

Finally in index.html, we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Photo App</title>
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

This adds the Bootstrap CSS file in the link tag and change the title of the app.

After writing all that code, we can run our app. First start the backend by running npm start in the backend folder and npm start in the frontend folder, then choose ‘yes’ if you’re asked to run it from a different port.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *